From 9576efb1bc530b8c18b77cbe222b5c18663bde45 Mon Sep 17 00:00:00 2001 From: "Teppei.F" <37261985+T3pp31@users.noreply.github.com> Date: Thu, 14 May 2026 00:31:46 +0900 Subject: [PATCH 1/3] Refactor CTF definitions into feature modules --- src/features/ctf/detectors.ts | 87 +++++++++++++++++++ src/features/ctf/flagPatterns.ts | 37 ++++++++ src/features/ctf/hints.ts | 41 +++++++++ src/features/ctf/transforms.ts | 91 ++++++++++++++++++++ src/features/ctf/types.ts | 56 ++++++++++++ src/pages/CtfPage.tsx | 143 +++++++++++++++++++------------ tests/ctfFeatures.test.ts | 84 ++++++++++++++++++ 7 files changed, 485 insertions(+), 54 deletions(-) create mode 100644 src/features/ctf/detectors.ts create mode 100644 src/features/ctf/flagPatterns.ts create mode 100644 src/features/ctf/hints.ts create mode 100644 src/features/ctf/transforms.ts create mode 100644 src/features/ctf/types.ts create mode 100644 tests/ctfFeatures.test.ts diff --git a/src/features/ctf/detectors.ts b/src/features/ctf/detectors.ts new file mode 100644 index 0000000..de3186f --- /dev/null +++ b/src/features/ctf/detectors.ts @@ -0,0 +1,87 @@ +import { DEFAULT_FLAG_PATTERNS, buildFlagPatternRegex } from "./flagPatterns"; +import type { CtfDetection, CtfDetector } from "./types"; + +const BASE64_CANDIDATE_PATTERN = /(?:[A-Za-z0-9+/]{4}){3,}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?/g; +const HEX_CANDIDATE_PATTERN = /(?:0x)?(?:[0-9a-fA-F]{2}[\s:-]?){4,}/g; +const URL_ENCODED_PATTERN = /%(?:[0-9a-fA-F]{2})/g; + +const createDetection = ( + type: string, + confidence: number, + start: number, + end: number, + recommendedActions: readonly string[], +): CtfDetection => ({ + type, + confidence, + range: { start, end }, + recommendedActions, +}); + +const detectionLength = (detection: CtfDetection): number => + detection.range.end - detection.range.start; + +const removeOverlappingDetections = (detections: readonly CtfDetection[]): readonly CtfDetection[] => { + const selectedDetections = [...detections] + .sort((left, right) => + right.confidence - left.confidence || detectionLength(right) - detectionLength(left), + ) + .reduce((selected, detection) => { + const overlaps = selected.some( + (selectedDetection) => + detection.range.start < selectedDetection.range.end && + detection.range.end > selectedDetection.range.start, + ); + + return overlaps ? selected : [...selected, detection]; + }, []); + + return selectedDetections.sort((left, right) => left.range.start - right.range.start); +}; + +export const CTF_DETECTORS = [ + { + id: "flag-pattern", + label: "Flag Pattern", + detect: (input, options) => { + const patterns = options?.flagPatterns ?? DEFAULT_FLAG_PATTERNS; + const detections = patterns.flatMap((pattern) => + Array.from(input.matchAll(buildFlagPatternRegex(pattern)), (match) => + createDetection( + pattern.id, + 0.95, + match.index, + match.index + match[0].length, + ["検出した flag 候補を提出前に問題文の形式と照合してください。"], + ), + ), + ); + + return removeOverlappingDetections(detections); + }, + }, + { + id: "base64-candidate", + label: "Base64 Candidate", + detect: (input) => + Array.from(input.matchAll(BASE64_CANDIDATE_PATTERN), (match) => + createDetection("base64", 0.7, match.index, match.index + match[0].length, ["base64-decode"]), + ), + }, + { + id: "hex-candidate", + label: "Hex Candidate", + detect: (input) => + Array.from(input.matchAll(HEX_CANDIDATE_PATTERN), (match) => + createDetection("hex", 0.65, match.index, match.index + match[0].length, ["hex-decode"]), + ), + }, + { + id: "url-encoded-candidate", + label: "URL Encoded Candidate", + detect: (input) => + Array.from(input.matchAll(URL_ENCODED_PATTERN), (match) => + createDetection("url-encoded", 0.6, match.index, match.index + match[0].length, ["url-decode"]), + ), + }, +] as const satisfies readonly CtfDetector[]; diff --git a/src/features/ctf/flagPatterns.ts b/src/features/ctf/flagPatterns.ts new file mode 100644 index 0000000..b3d8591 --- /dev/null +++ b/src/features/ctf/flagPatterns.ts @@ -0,0 +1,37 @@ +import type { FlagPattern } from "./types"; + +export const DEFAULT_FLAG_PATTERNS = [ + { + id: "flag-braces", + label: "flag{...}", + source: "flag\\{[^}]+\\}", + flags: "gi", + description: "一般的な flag{...} 形式を検出します。", + }, + { + id: "ctf-braces", + label: "ctf{...}", + source: "ctf\\{[^}]+\\}", + flags: "gi", + description: "イベント名を省略した ctf{...} 形式を検出します。", + }, + { + id: "htb-braces", + label: "HTB{...}", + source: "HTB\\{[^}]+\\}", + flags: "g", + description: "Hack The Box でよく使われる HTB{...} 形式を検出します。", + }, + { + id: "picoctf-braces", + label: "picoCTF{...}", + source: "picoCTF\\{[^}]+\\}", + flags: "g", + description: "picoCTF でよく使われる picoCTF{...} 形式を検出します。", + }, +] as const satisfies readonly FlagPattern[]; + +export const buildFlagPatternRegex = (pattern: FlagPattern): RegExp => { + const flags = pattern.flags?.includes("g") ? pattern.flags : `${pattern.flags ?? ""}g`; + return new RegExp(pattern.source, flags); +}; diff --git a/src/features/ctf/hints.ts b/src/features/ctf/hints.ts new file mode 100644 index 0000000..ea6f4ab --- /dev/null +++ b/src/features/ctf/hints.ts @@ -0,0 +1,41 @@ +import type { CtfHintSection } from "./types"; + +export const CTF_HINT_SECTIONS = [ + { + title: "Forensics", + items: [ + "Log analysis: grep, grep -v (exclude), uniq (deduplicate), sort", + ], + }, + { + title: "Web", + items: ["Burpsuite"], + }, + { + title: "Network", + items: ["Wireshark", "Scapy", "socket", "Port scan: nmap"], + }, + { + title: "Binary", + items: [ + "Binary editor: Hex Fiend", + "file: identify binary file type", + "less: view file contents", + "strings: extract readable strings", + ], + }, + { + title: "Encoding", + items: [ + "Base64: A-Z, a-z, 0-9, +, / (64 chars), ends with =", + 'Decode: echo "encoded_string" | base64 -d -o output', + ], + }, + { + title: "File Location", + items: [ + "locate: find file location", + "VS Code SSH: browse files via GUI", + ], + }, +] as const satisfies readonly CtfHintSection[]; diff --git a/src/features/ctf/transforms.ts b/src/features/ctf/transforms.ts new file mode 100644 index 0000000..c89d2e8 --- /dev/null +++ b/src/features/ctf/transforms.ts @@ -0,0 +1,91 @@ +import type { CtfTransform } from "./types"; + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +const fromBase64 = (input: string): string => { + const binary = atob(input.replace(/\s/g, "")); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return textDecoder.decode(bytes); +}; + +const toBase64 = (input: string): string => { + const bytes = textEncoder.encode(input); + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""); + return btoa(binary); +}; + +const fromHex = (input: string): string => { + const normalizedInput = input.replace(/(?:0x|\s|:|-)/gi, ""); + if (normalizedInput.length % 2 !== 0 || /[^0-9a-f]/i.test(normalizedInput)) { + return ""; + } + + const bytes = normalizedInput.match(/.{2}/g)?.map((hex) => parseInt(hex, 16)) ?? []; + return textDecoder.decode(Uint8Array.from(bytes)); +}; + +const rotateAsciiLetter = (character: string, shift: number): string => { + const code = character.charCodeAt(0); + const upperA = 65; + const upperZ = 90; + const lowerA = 97; + const lowerZ = 122; + + if (code >= upperA && code <= upperZ) { + return String.fromCharCode(((code - upperA + shift) % 26) + upperA); + } + + if (code >= lowerA && code <= lowerZ) { + return String.fromCharCode(((code - lowerA + shift) % 26) + lowerA); + } + + return character; +}; + +export const CTF_TRANSFORMS = [ + { + id: "base64-decode", + label: "Base64 Decode", + category: "encoding", + description: "Base64 文字列を UTF-8 テキストとして復号します。", + run: (input) => ({ output: fromBase64(input) }), + }, + { + id: "base64-encode", + label: "Base64 Encode", + category: "encoding", + description: "UTF-8 テキストを Base64 文字列に変換します。", + run: (input) => ({ output: toBase64(input) }), + }, + { + id: "url-decode", + label: "URL Decode", + category: "web", + description: "URL エンコードされた文字列を復号します。", + run: (input) => ({ output: decodeURIComponent(input.replace(/\+/g, " ")) }), + }, + { + id: "url-encode", + label: "URL Encode", + category: "web", + description: "文字列を URL コンポーネントとしてエンコードします。", + run: (input) => ({ output: encodeURIComponent(input) }), + }, + { + id: "hex-decode", + label: "Hex Decode", + category: "encoding", + description: "16 進表現を UTF-8 テキストとして復号します。", + run: (input) => ({ output: fromHex(input) }), + }, + { + id: "rot13", + label: "ROT13", + category: "crypto", + description: "英字に ROT13 を適用します。", + run: (input) => ({ + output: Array.from(input, (character) => rotateAsciiLetter(character, 13)).join(""), + }), + }, +] as const satisfies readonly CtfTransform[]; diff --git a/src/features/ctf/types.ts b/src/features/ctf/types.ts new file mode 100644 index 0000000..58d9184 --- /dev/null +++ b/src/features/ctf/types.ts @@ -0,0 +1,56 @@ +export type CtfTransformCategory = + | "encoding" + | "crypto" + | "forensics" + | "network" + | "web" + | "binary"; + +export interface CtfTransformOptions { + readonly flagPatterns?: readonly FlagPattern[]; + readonly [key: string]: unknown; +} + +export interface CtfTransformResult { + readonly output: string; + readonly notes?: readonly string[]; +} + +export interface CtfTransform { + readonly id: string; + readonly label: string; + readonly category: CtfTransformCategory; + readonly description: string; + run(input: string, options?: CtfTransformOptions): CtfTransformResult; +} + +export interface DetectionRange { + readonly start: number; + readonly end: number; +} + +export interface CtfDetection { + readonly type: string; + readonly confidence: number; + readonly range: DetectionRange; + readonly recommendedActions: readonly string[]; +} + +export interface CtfDetector { + readonly id: string; + readonly label: string; + detect(input: string, options?: CtfTransformOptions): readonly CtfDetection[]; +} + +export interface FlagPattern { + readonly id: string; + readonly label: string; + readonly source: string; + readonly flags?: string; + readonly description: string; +} + +export interface CtfHintSection { + readonly title: string; + readonly items: readonly string[]; +} diff --git a/src/pages/CtfPage.tsx b/src/pages/CtfPage.tsx index 8ab48b0..4a4617d 100644 --- a/src/pages/CtfPage.tsx +++ b/src/pages/CtfPage.tsx @@ -1,46 +1,10 @@ import React from "react"; +import { CTF_DETECTORS } from "../features/ctf/detectors"; +import { DEFAULT_FLAG_PATTERNS } from "../features/ctf/flagPatterns"; +import { CTF_HINT_SECTIONS } from "../features/ctf/hints"; +import { CTF_TRANSFORMS } from "../features/ctf/transforms"; -const SECTIONS = [ - { - title: "Forensics", - items: [ - "Log analysis: grep, grep -v (exclude), uniq (deduplicate), sort", - ], - }, - { - title: "Web", - items: ["Burpsuite"], - }, - { - title: "Network", - items: ["Wireshark", "Scapy", "socket", "Port scan: nmap"], - }, - { - title: "Binary", - items: [ - "Binary editor: Hex Fiend", - "file: identify binary file type", - "less: view file contents", - "strings: extract readable strings", - ], - }, - { - title: "Encoding", - items: [ - "Base64: A-Z, a-z, 0-9, +, / (64 chars), ends with =", - 'Decode: echo "encoded_string" | base64 -d -o output', - ], - }, - { - title: "File Location", - items: [ - "locate: find file location", - "VS Code SSH: browse files via GUI", - ], - }, -] as const; - -const sectionStyle: React.CSSProperties = { +const cardStyle: React.CSSProperties = { backgroundColor: "var(--bg-secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius)", @@ -48,6 +12,18 @@ const sectionStyle: React.CSSProperties = { marginBottom: "12px", }; +const gridStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", + gap: "12px", +}; + +const metadataStyle: React.CSSProperties = { + color: "var(--text-secondary)", + fontSize: "12px", + marginTop: "4px", +}; + export const CtfPage: React.FC = () => { return (
@@ -55,20 +31,79 @@ export const CtfPage: React.FC = () => { CTF Reference - {SECTIONS.map((section) => ( -
-

- {section.title} -

-
    - {section.items.map((item, i) => ( -
  • - {item} -
  • - ))} -
+
+

+ Hints +

+ {CTF_HINT_SECTIONS.map((section) => ( +
+

+ {section.title} +

+
    + {section.items.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ ))} +
+ +
+

+ Transforms +

+
+ {CTF_TRANSFORMS.map((transform) => ( +
+

+ {transform.label} +

+

+ {transform.description} +

+

Category: {transform.category}

+
+ ))} +
+
+ +
+

+ Detectors +

+
+ {CTF_DETECTORS.map((detector) => ( +
+

+ {detector.label} +

+

ID: {detector.id}

+
+ ))} +
+
+ +
+

+ Flag Patterns +

+
+ {DEFAULT_FLAG_PATTERNS.map((pattern) => ( +
+

+ {pattern.label} +

+

+ {pattern.description} +

+

/{pattern.source}/{pattern.flags}

+
+ ))}
- ))} +
); }; diff --git a/tests/ctfFeatures.test.ts b/tests/ctfFeatures.test.ts new file mode 100644 index 0000000..4b09fd3 --- /dev/null +++ b/tests/ctfFeatures.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { CTF_DETECTORS } from "../src/features/ctf/detectors"; +import { DEFAULT_FLAG_PATTERNS } from "../src/features/ctf/flagPatterns"; +import { CTF_HINT_SECTIONS } from "../src/features/ctf/hints"; +import { CTF_TRANSFORMS } from "../src/features/ctf/transforms"; + +const getTransform = (id: string) => { + const transform = CTF_TRANSFORMS.find((candidate) => candidate.id === id); + expect(transform).toBeDefined(); + return transform; +}; + +const getDetector = (id: string) => { + const detector = CTF_DETECTORS.find((candidate) => candidate.id === id); + expect(detector).toBeDefined(); + return detector; +}; + +describe("CTF feature modules", () => { + it("ヒント定義をページ外のモジュールから参照できること", () => { + expect(CTF_HINT_SECTIONS.length).toBeGreaterThan(0); + expect(CTF_HINT_SECTIONS.map((section) => section.title)).toContain("Forensics"); + }); + + it("各 transform が必要なメタデータと run を持つこと", () => { + expect(CTF_TRANSFORMS.length).toBeGreaterThan(0); + for (const transform of CTF_TRANSFORMS) { + expect(transform.id).not.toHaveLength(0); + expect(transform.label).not.toHaveLength(0); + expect(transform.category).not.toHaveLength(0); + expect(transform.description).not.toHaveLength(0); + expect(transform.run).toBeInstanceOf(Function); + } + }); + + it("Base64 transform が UTF-8 文字列を往復変換できること", () => { + const encoder = getTransform("base64-encode"); + const decoder = getTransform("base64-decode"); + const encoded = encoder?.run("CTF テスト").output; + + expect(encoded).toBe("Q1RGIOODhuOCueODiA=="); + expect(decoder?.run(encoded ?? "").output).toBe("CTF テスト"); + }); + + it("flag pattern が固定値ではなく初期設定リストとして管理されること", () => { + expect(DEFAULT_FLAG_PATTERNS.map((pattern) => pattern.label)).toEqual([ + "flag{...}", + "ctf{...}", + "HTB{...}", + "picoCTF{...}", + ]); + }); + + it("flag detector が設定された正規表現から検出範囲と推奨アクションを返すこと", () => { + const detector = getDetector("flag-pattern"); + const detections = detector?.detect("answer is picoCTF{sample_flag}") ?? []; + + expect(detections).toHaveLength(1); + expect(detections[0]).toMatchObject({ + type: "picoctf-braces", + confidence: 0.95, + range: { start: 10, end: 30 }, + }); + expect(detections[0]?.recommendedActions.length).toBeGreaterThan(0); + }); + + it("flag detector が UI 追加を想定したカスタムパターンを受け取れること", () => { + const detector = getDetector("flag-pattern"); + const detections = detector?.detect("custom KEY-1234", { + flagPatterns: [ + { + id: "custom-key", + label: "KEY-####", + source: "KEY-\\d{4}", + flags: "g", + description: "テスト用の追加パターンです。", + }, + ], + }) ?? []; + + expect(detections).toHaveLength(1); + expect(detections[0]?.type).toBe("custom-key"); + }); +}); From 0aadf764a367c0e9d17bc60043750f8faa40605c Mon Sep 17 00:00:00 2001 From: fukutomiteppei Date: Fri, 15 May 2026 00:13:37 +0900 Subject: [PATCH 2/3] Group URL encoded CTF detections --- src/features/ctf/detectors.ts | 2 +- tests/ctfFeatures.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/features/ctf/detectors.ts b/src/features/ctf/detectors.ts index de3186f..8e4693a 100644 --- a/src/features/ctf/detectors.ts +++ b/src/features/ctf/detectors.ts @@ -3,7 +3,7 @@ import type { CtfDetection, CtfDetector } from "./types"; const BASE64_CANDIDATE_PATTERN = /(?:[A-Za-z0-9+/]{4}){3,}(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?/g; const HEX_CANDIDATE_PATTERN = /(?:0x)?(?:[0-9a-fA-F]{2}[\s:-]?){4,}/g; -const URL_ENCODED_PATTERN = /%(?:[0-9a-fA-F]{2})/g; +const URL_ENCODED_PATTERN = /(?:%[0-9a-fA-F]{2})+/g; const createDetection = ( type: string, diff --git a/tests/ctfFeatures.test.ts b/tests/ctfFeatures.test.ts index 4b09fd3..98f8c01 100644 --- a/tests/ctfFeatures.test.ts +++ b/tests/ctfFeatures.test.ts @@ -81,4 +81,17 @@ describe("CTF feature modules", () => { expect(detections).toHaveLength(1); expect(detections[0]?.type).toBe("custom-key"); }); + + it("URL encoded detector が連続したエンコード文字列を1件として検出すること", () => { + const detector = getDetector("url-encoded-candidate"); + const input = "token=%66%6c%61%67%7Bsample%7D"; + const detections = detector?.detect(input) ?? []; + + expect(detections).toHaveLength(1); + expect(detections[0]).toMatchObject({ + type: "url-encoded", + range: { start: 6, end: input.length }, + recommendedActions: ["url-decode"], + }); + }); }); From fdaf1744cfed564e469633cddac0ac2e323afc3c Mon Sep 17 00:00:00 2001 From: fukutomiteppei Date: Fri, 15 May 2026 00:29:23 +0900 Subject: [PATCH 3/3] Use contiguous encoded sample in CTF detector test --- tests/ctfFeatures.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ctfFeatures.test.ts b/tests/ctfFeatures.test.ts index 98f8c01..521d375 100644 --- a/tests/ctfFeatures.test.ts +++ b/tests/ctfFeatures.test.ts @@ -84,7 +84,7 @@ describe("CTF feature modules", () => { it("URL encoded detector が連続したエンコード文字列を1件として検出すること", () => { const detector = getDetector("url-encoded-candidate"); - const input = "token=%66%6c%61%67%7Bsample%7D"; + const input = "token=%66%6c%61%67%7B%73%61%6d%70%6c%65%7D"; const detections = detector?.detect(input) ?? []; expect(detections).toHaveLength(1);