概要
久しぶりの投稿&CTF自体がかなり久し振りです。
本記事は、2026/6/13 (土) 14:00 JST から 2026/6/14 (日) 14:00 JST で実施されたctf4b2026のwriteUpです。
回答できた問題を中心に記載していきます。
※体調不良と別件があり、前回ほど時間は取れていませんが、解けた範囲で書いていきます。
結果
今回も1マンチームで参加し、
最終結果は、326/630 th、1100 point でした。
時間割けなかった割には去年より点数高いですね・・・(去年も同じこと言ってますね)
※今回からAIの使用について、強制的に登録が必要になっていました。が、なかなか仰々しい名前ですね・・・AIが主体か人間が主体かの2択しかありませんでしたので・・・・
環境
Windows + Kalilinux
プログラムはすべてPython3
その他、各種ツール
Welcome
ようこそ。
前回と同じです。
問題文にフラグが提示されてます。
Web
portfolio
はじめて作ったポートフォリオサイトを公開しました。 http://portfolio.beginners.seccon.games:33455
配布されたコードには、admin画面にアクセスした際に無条件に403にされるルーティングしか設定されていない。
flag.txtのパスは既知の情報であるため、直接flag.txtにアクセスすればよい。
bookshelf
書籍レビューサイトを公開しました。
http://bookshelf.beginners.seccon.games:33456
\app\books\[id]\page.tsxのソース内に、それぞれのidの画面に渡すデータが設定されている。
その中に、id=2の際のデータにフラグが設定されていることが確認できる。
あとはサイトの構成上、/2のレスポンスを確認すればフラグが確認できる。
(前略)
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,
},
];
(後略)
Crypt
twins
RSAの公開鍵を2つ作ってみました!
import os
from math import gcd
from pathlib import Path
from Crypto.Util.number import bytes_to_long, getPrime, long_to_bytes
FLAG = os.getenv("FLAG", "ctf4b{dummy_flag}").encode()
m = bytes_to_long(FLAG)
p = getPrime(512)
q1 = getPrime(512)
q2 = getPrime(512)
n1 = p * q1
n2 = p * q2
e = 65537
c = pow(m, e, n1)
assert m < n1
assert gcd(n1, n2) == p
assert gcd(n1, n2) != 1
phi = (p - 1) * (q1 - 1)
d = pow(e, -1, phi)
assert long_to_bytes(pow(c, d, n1)) == FLAG
output = "\n".join([
f"n1 = {n1}",
f"n2 = {n2}",
f"e = {e}",
f"c = {c}",
])
Path(__file__).with_name("output.txt").write_text(output + "\n")
print(output)
n1 = 113880125198864950563107027313262858562579845169040599843244908620910863016550189657973462371771316792957786246501938664222083662393621306146341461444713328361747021647851967048322504706224619037767570006428934894715790606442824792889611101483284328574709759135027127435315296666269676354975141117473271303321
n2 = 105276345432465274963720722422774590917691220487589268040722899198110433602948101271470062639646432750400949217388976818212349789839198599553407451456746354799769561549120245004545841153155597677559555183979871182443047548869983267425969009743794827412550143572792952463128292049048381620452468992949469524739
e = 65537
c = 523724402186281757672504163102213938199531542190538176894240854156187650675308897872801979844253859168041096691538816812473265236344118603761747753962742078327527050567766258109597769320400583993445607364727303667121730062147288257549149953588914416172323461076634838540811797057063184864794352115558365151
n1,n2,e,cがすでに明示されています。
またコードより、n1とn2の最大公約数がpであることが明示されています。
すなわち、
gcd(n1, n2) = gcd(p*q1, p*q2) = p * gcd(q1, q2) = p
よって、p=gcd(n1, n2)となります。
さらにpが求まれば、定義からq1=n1//pにてq1が求まります。
ここまで求まれば、phi=(p-1)*(q1-1)も計算でき、さらに秘密鍵d=pow(e, -1, phi)、フラグm = pow(c, d, n1)も逆順で計算できます。
#諸事情でCrypt使ってないですが、
#from Crypto.Util.number import inverse, long_to_bytes
#使った方が楽です
from math import gcd
n1 = 113880125198864950563107027313262858562579845169040599843244908620910863016550189657973462371771316792957786246501938664222083662393621306146341461444713328361747021647851967048322504706224619037767570006428934894715790606442824792889611101483284328574709759135027127435315296666269676354975141117473271303321
n2 = 105276345432465274963720722422774590917691220487589268040722899198110433602948101271470062639646432750400949217388976818212349789839198599553407451456746354799769561549120245004545841153155597677559555183979871182443047548869983267425969009743794827412550143572792952463128292049048381620452468992949469524739
e = 65537
c = 523724402186281757672504163102213938199531542190538176894240854156187650675308897872801979844253859168041096691538816812473265236344118603761747753962742078327527050567766258109597769320400583993445607364727303667121730062147288257549149953588914416172323461076634838540811797057063184864794352115558365151
def egcd(a, b):
if b == 0:
return a, 1, 0
g, x1, y1 = egcd(b, a % b)
return g, y1, x1 - (a // b) * y1
def inverse(a, m):
g, x, _ = egcd(a, m)
return x % m
def long_to_bytes(n):
return n.to_bytes((n.bit_length() + 7) // 8, "big")
p = gcd(n1, n2)
q1 = n1 // p
phi = (p - 1) * (q1 - 1)
d = inverse(e, phi)
m = pow(c, d, n1)
print(long_to_bytes(m))
reversing
baby-rev
Welcome to the world of Reversing! I hope you find it interesting :)
#include <stdio.h>
int main()
{
const unsigned char xorFlag[] = {0xeb,0xfc,0xee,0xbc,0xea,0xf3,0xe4,0xb8,0xb8,0xe3,0xd7,0xe5,0xb8,0xe5,0xd7,0xe6,0xb8,0xd7,0xe0,0xbc,0xe6,0xec,0xfb,0xd7,0xe2,0xfd,0xfb,0xfc,0xd7,0xf0,0xb8,0xfa,0xa9,0xf5};
const int xorKey = 0x88;
const int len = 34;
unsigned char inp[64] = {0};
printf("INPUT > ");
if (scanf("%63s", inp) != 1) {
printf("Wrong:(\n");
return -1;
}
int inputLen = 0;
while (inp[inputLen] != '\0') inputLen++;
if (inputLen != len) {
printf("Wrong:(\n");
return -1;
}
for (int i = 0; i < len; i++) {
if ((inp[i] ^ xorKey) != xorFlag[i]) {
printf("Wrong:(\n");
return -1;
}
}
printf("Correct:)\n");
return 0;
}
暗号化処理内に、XOR元(xorFlag)とXORキー(xorFlag)がハードコートされているのでXORするのみでフラグが取れます。
python3 -c 'data=[0xeb,0xfc,0xee,0xbc,0xea,0xf3,0xe4,0xb8,0xb8,0xe3,0xd7,0xe5,0xb8,0xe5,0xd7,0xe6,0xb8,0xd7,0xe0,0xbc,0xe6,0xec,0xfb,0xd7,0xe2,0xfd,0xfb,0xfc,0xd7,0xf0,0xb8,0xfa,0xa9,0xf5]; print(bytes(x^0x88 for x in data))'
1st-Memory-Errand
Your mother: Could you run a quick errand for me and look for the FLAG in Memory? It might be in a hard-to-find spot, but if you look for it, you'll find it, so please do me this favor.
配布ファイルをGhidra通したらキレイにデコンパイルできたので、とりあえずmain関数を確認。
int main(void)
{
byte bVar3;
int iVar4;
long in_FS_OFFSET;
size_t i;
uchar flag [38];
char dummy [64];
byte bVar1;
long lVar2;
lVar2 = *(long *)(in_FS_OFFSET + 0x28);
puts("Mom: Go fetch the flag from memory!");
puts("You: OK! Heading out...");
for (i = 0; i < 0x25; i = i + 1) {
bVar1 = enc[i];
bVar3 = gen_key((int)i);
flag[i] = bVar1 ^ bVar3;
}
flag[0x25] = '\0';
puts("You: I\'m at the memory store now. Looking around...");
if (flag[0] == 0xff) {
puts((char *)flag);
}
printf("Mom: Did you find it? Type what you got: ");
iVar4 = __isoc99_scanf(&DAT_004020ea,dummy);
if (iVar4 == 1) {
iVar4 = strcmp(dummy,(char *)flag);
if (iVar4 == 0) {
puts("Mom: Well done! That\'s my child :)");
}
else {
puts("Mom: Hmm, that doesn\'t look right. Go check again!");
}
}
if (lVar2 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}
mainのコードを読むと
①変数flagにフラグとなる文字列を入れ、最終的に入力文字列と比較し、一致していれば成功している旨のメッセージを出力
②変数flagにはbVar1とbVar3がXORされた結果が代入されている
③bVar1にはencというグローバル変数が設定されている。
(久しぶりにGhidra触ったので画面の見方にてこずりました。
デコンパイル結果のencをダブルクリックすることで、画面中央のListingにenc内のデータが表示されています)
④bVar3の値はgen_key((int)i)
uchar gen_key(int i)
{
return ((char)(i << 3) - (char)i) + '!';
}
より、
(i << 3) - i = 8i - i = 7i
↓
gen_key(i) = (char)(7i) + 0x21
以上①~④より、以下の復号化コードにてフラグを取得。
enc = [0x42, 0x5c, 0x49, 0x02, 0x5f, 0x3f, 0x06, 0x2b,
0x06, 0x06, 0x0e, 0x1c, 0x40, 0x08,
0xdc, 0xb9, 0xe3, 0xea, 0xab, 0xc8,
0xc9, 0xeb, 0xcc, 0xf6, 0xfc, 0x8f,
0xe3, 0x81, 0xd0, 0x99, 0x90, 0x99,
0x32, 0x3d, 0x3a, 0x37, 0x60]
flag = []
for i, c in enumerate(enc):
key = (7 * i + 0x21) & 0xff
flag.append(c ^ key)
print(bytes(flag))
Reversing-2050
Let’s give the next-generation programming language a try:) 実行を行う場合は
pip install qsharp
python Main.py
で行えます
namespace QuantumCipher2050 {
open Microsoft.Quantum.Intrinsic;
function KeyBytes() : Int[] {
return [0x51, 0x75, 0x61, 0x6E, 0x74, 0x75, 0x6D];
}
function CipherText() : Int[] {
return [
0x32, 0x01, 0x07, 0x5A, 0x16, 0x0E, 0x25, 0x34,
0x19, 0x0D, 0x01, 0x2B, 0x24, 0x18, 0x30, 0x1B,
0x15, 0x1B, 0x19, 0x2A, 0x3A, 0x3E, 0x07, 0x0D,
0x0A, 0x55, 0x54, 0x4C, 0x2C
];
}
operation EncryptQuantum(dataByte : Int, keyByte : Int) : Int {
use reg = Qubit[8];
mutable result = 0;
for i in 0..7 {
if (dataByte >>> i) &&& 1 == 1 { X(reg[i]); }
if (keyByte >>> i) &&& 1 == 1 { X(reg[i]); }
}
for i in 0..7 {
if M(reg[i]) == One { set result |||= (1 <<< i); }
}
ResetAll(reg);
return result;
}
operation CheckFlag(input : Int[]) : Bool {
let ct = CipherText();
let key = KeyBytes();
let klen = Length(key);
if Length(input) != Length(ct) {
return false;
}
mutable ok = true;
for i in 0..Length(input) - 1 {
let kb = key[i % klen];
let xored = EncryptQuantum(input[i], kb);
if xored != ct[i] {
set ok = false;
}
}
return ok;
}
}
暗号化処理の本体はEncrypt.qs内で実施されている。
しかしEncryptQuantumで仰々しいことしているように見えて、実質XOR演算をQ#でしているだけ。
(量子暗号化関係なし。見た目だけでした)
このメソッドの返り値がフラグ。
よって、問題文のコードに沿って、古典計算で復号してフラグを取得する。
key = [0x51, 0x75, 0x61, 0x6E, 0x74, 0x75, 0x6D]
ct = [
0x32, 0x01, 0x07, 0x5A, 0x16, 0x0E, 0x25, 0x34,
0x19, 0x0D, 0x01, 0x2B, 0x24, 0x18, 0x30, 0x1B,
0x15, 0x1B, 0x19, 0x2A, 0x3A, 0x3E, 0x07, 0x0D,
0x0A, 0x55, 0x54, 0x4C, 0x2C
]
flag = []
for i in range(len(ct)):
v = ct[i] ^ key[i % len(key)]
flag.append(v)
print(flag)
print(bytes(flag))
Pwnable
login
まずは手始めにadminを目指しましょう
nc login.beginners.seccon.games 9080
単純にスタックオーバーフローの問題。
ソースコードより、
#include <stdio.h>
struct user {
char username[0x10];
int is_admin;
};
void win() {
system("/bin/sh");
}
static void setup(void) {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
int main() {
setup();
struct user normal_user = {0};
printf("Input username: ");
fgets(normal_user.username, sizeof(struct user), stdin);
normal_user.username[strcspn(normal_user.username, "\n")] = '\0';
if (normal_user.is_admin) {
printf("Welcome, admin %s!\n", normal_user.username);
win();
} else {
printf("Welcome, %s!\n", normal_user.username);
}
return 0;
}
と記述されており、バッファオーバーフローしてwin関数を呼び出すだけ。
misc
omikuji
名前を入れておみくじを引きましょう。結果を全部当てられますか?
nc omikuji.beginners.seccon.games 33457
import os
import random
import sys
FLAG = os.getenv("FLAG", "ctf4b{dummy_flag}")
ROUNDS = 5
MAX_NAME_LENGTH = 64
MAX_GUESS_LENGTH = 16
def read_limited(prompt, max_length):
sys.stdout.write(prompt)
sys.stdout.flush()
line = sys.stdin.readline(max_length + 2)
if not line:
sys.exit()
value = line.rstrip("\r\n")
if len(value) > max_length or (not line.endswith("\n") and len(line) > max_length):
print("wrong")
sys.exit()
return value.strip()
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}")
メインのコードの内容は、サーバー側で発生させたランダムな数値を5回連続で正解した場合にのみフラグ文字列を出力するというもの。なお発生した数値は入力側からは確認できない。
しかし、サーバー接続時にユーザー名を入力させ、その値をシードしとしてランダムな数値を発生させている。
ローカルで配布されたコードを実行してみると、入力した文字列が完全一致であれば、必ずまったく同じランダムな数値が発生、すなわちシード値によりランダムな値は完全に1対1で対応している。
この性質を利用し、ローカルで値を確認後、サーバーで同じ値を入力することでフラグを獲得可能。
※利用しているrandomは“疑似乱数”であり、seedが同じなら出力列は100%一致する。
※state遷移・出力関数も決定論的。
#main2.py は、配布されたコード内に、入力すべき値を標準出力させるようにデバグコード差し込んでいます。
└─# python3 main2.py
=== 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.
342042
guess 1 > 342042
246190
guess 2 > 246190
401986
guess 3 > 401986
352872
guess 4 > 352872
821335
guess 5 > 821335
Congratulations! Here is your flag: ctf4b{dummy_flag}
# 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}
viewer
表示できるファイルを選んでください。
nc viewer.beginners.seccon.games 33458
import os
import sys
import unicodedata
FILES_DIR = "/app/files"
ALLOWED_FILES = {"readme.txt", "hello.txt", "flag.txt"}
MAX_FILENAME_LENGTH = 64
BANNER = r"""
__ ___
\ \ / (_) _____ _____ _ __
\ \ / /| |/ _ \ \ /\ / / _ \ '__|
\ V / | | __/\ V V / __/ |
\_/ |_|\___| \_/\_/ \___|_|
"""
def read_limited(prompt, max_length):
sys.stdout.write(prompt)
sys.stdout.flush()
line = sys.stdin.readline(max_length + 2)
if not line:
sys.exit()
value = line.rstrip("\r\n")
if len(value) > max_length or (not line.endswith("\n") and len(line) > max_length):
print("invalid path")
sys.exit()
return value.strip()
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():
print(BANNER)
print("available files:")
print("- readme.txt")
print("- hello.txt")
filename = read_limited("filename > ", MAX_FILENAME_LENGTH)
if "flag" in filename:
print("blocked")
return
path, error = resolve_path(filename)
if error is not None:
print(error)
return
try:
with open(path, encoding="utf-8") as f:
print(f.read(), end="")
except (FileNotFoundError, IsADirectoryError):
print("file not found")
if __name__ == "__main__":
main()
問題文のコードを確認すると
- 入力内容の確認にて、
- 許可されている文字列以外はBAN
- 許可されている文字列に
flagが含まれているが、flag.txtは弾く
-
resolve_pathメソッド(ファイル名を元に存在性などを判定)にて正規化を正規形 KC (NFKC)で実施していることが確認できる。
上記より、
flagと似た文字列であり完全一致でない文字列、かつ、正規化後に同じ内容に解釈される文字列を入力すればよい。
今回は、全角文字で入力(メモ:windowsのF9キーですぐに全角化可能)、すなわち、
flag.txt
を入力することで、フラグを取得可能。
Homework
My teacher told me to do this assignment. I’d like to use AI to make it easier, but I have a feeling there’s something hidden here...
実施した解法
少々長かったので、手順をナンバリングします。
①初期調査
配布されているファイルはHomework.pdfのみであり、ファイル内に不審な点はなく、文章内に暗号や黒塗りや透かしも無さそう。
そのためまずは、フォレンジックやrevなどの解析系と同様に、初期調査として以下を実行。
file problem.pdf
pdfinfo problem.pdf
strings problem.pdf
今回はstringsで以下を発見
FLAG.txt
PK
②PKを見たらZIPを疑う
①の結果でPK文字列がヒットしていることを確認。
これは、ZIPの代表的なシグネチャである。
PK 03 04
PK 01 02
PK 05 06
PK0304 : Local File Header
PK0102 : Central Directory
PK0506 : End of Central Directory
③binwalkで確認
PKが見えたので、binwalkで解析・抽出できないか調査。
# binwalk Homework.pdf
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
0 0x0 PDF document, version: "1.4"
734 0x2DE Zlib compressed data, default compression
2890 0xB4A Zip archive data, at least v2.0 to extract, compressed size: 37, uncompressed size: 35, name: FLAG.txt
3019 0xBCB End of Zip archive, footer length: 22
結果として、ZIPらしきものを検出。
ただし結果を盲信しない。
実際、抽出しようとしてみたが、ファイル形式がおかしいか破損しているか、抽出できず。
# binwalk -e Homework.pdf
Extractor Exception: Binwalk extraction uses many third party utilities, which may not be secure. If you wish to have extraction utilities executed as the current user, use '--run-as=root' (binwalk itself must be run as root).
----------------------------------------------------------------------------------------------------
Traceback (most recent call last):
File "/usr/lib/python3/dist-packages/binwalk/core/module.py", line 258, in __init__
self.load()
~~~~~~~~~^^
File "/usr/lib/python3/dist-packages/binwalk/modules/extractor.py", line 153, in load
raise ModuleException("Binwalk extraction uses many third party utilities, which may not be secure. If you wish to have extraction utilities executed as the current user, use '--run-as=%s' (binwalk itself must be run as root)." % user_info.pw_name)
binwalk.core.exceptions.ModuleException: Binwalk extraction uses many third party utilities, which may not be secure. If you wish to have extraction utilities executed as the current user, use '--run-as=root' (binwalk itself must be run as root).
----------------------------------------------------------------------------------------------------
④PDF構造を確認
pdf-parser.py problem.pdfなどで、末尾にPDF Commentを発見。
実際に、ファイルstring内部の最後の辺りに、以下通りEOF以降に怪しいデータの存在を発見。
(中略)
startxref 2558
PDF Comment '%%EOF\n\n'
PDF Comment '%\x00\x00\x00#\x00\x00\x00\x08\x00\x00\x00FLAG.txtK.I3I\xaa\xf6\xcc\x8d\xb744I\x89\xaf4(\x8dO3(\xcdK\x89/\xc9\xc84\x8dO34\xb1\xac\xe5\x02\x00PK\x01\x02\x14\x00\x14\x00\x00\x00\x08\x00\xe9\x80\xca\\\x91\xcb\x845%\x00\x00\x00#\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80\x01\x00\x00\x00\x00FLAG.txtPK\x05\x06\x00\x00\x00\x00\x01\x00\x01\x006\x00\x00\x00K\x00\x00\x00\x00\x00'
⑤PKの位置を特定
ここまでで、配布されたPDFファイルにZIPが埋め込まれている可能性があると推測。
そこで、このZIPファイルを抽出するための調査を進める。
まずはPKの位置を特定する。
grep -abo "PK" problem.pdf
結果
2890:PK
2965:PK
3019:PK
⑥各PKの種類を確認
xxd problem.pdf
確認結果
2890 -> PK0304
2965 -> PK0102
3019 -> PK0506
⑦ZIP開始位置を確定
これまでの調査より、埋め込まれたIP構造は、
PK0304
↓
ファイルデータ
↓
PK0102
↓
PK0506
したがってZIP開始位置は2890と確定。
⑧ZIPを切り出す
ここまでの調査の結果を利用して、ddにて配布されたPDFファイルからZIPファイルを切り出す。
dd if=Homework.pdf of=extracted.zip bs=1 skip=2890
※通常は512バイト単位などで利用するコマンドだが、今回txt埋め込まれているような感じなので、細かい情報が落ちないように1バイト単位に指定
⑨切り出したファイルを確認
file extracted.zip
ZIPファイルとして認識されることを確認
⑩解凍
unzip extracted.zip
解凍後にFLAG.txtが展開され、そのファイル内にフラグが記載されていることを確認。
今後の課題
- 体調管理と長時間イベントへのガチ目の対策。無理が効かなくなってきました。(去年と同じ)
- 事前にスケジュールを調整する。
- そもそも環境構築を当日に実施しない。
- 睡眠時間や休憩時間をスケジュールする。(OSCPとかでも重要になると思います)
- 初動でより早く動けるように対策を考える。



