🔒

セキュリティ・キャンプ全国大会2025 - 脅威解析クラス 【応募課題】

に公開

はじめに

はじめまして、ぽちです。
この度、セキュリティ・キャンプ全国大会 脅威解析クラス(専門C)に合格し参加することとなったので、実際に提出した応募課題を公開しようと思います。

https://www.ipa.go.jp/jinzai/security-camp/2025/camp/zenkoku/index.html

来年度のセキュリティ・キャンプに申し込む予定の方の参考になれば幸いです。

尚、リバースエンジニアリング時の詳細な説明等は省略します。
後日、覚えていれば別の記事として投稿するのでよろしくお願いします。

課題

以下の問1から問7について、それぞれ回答してください。ただし問1〜3はそれぞれ2000文字以内で回答してください。
なお、正解がある設問については、"正解していること"よりも"正解にたどり着くまでのプロセスや熱意"を重要視しています。答えにたどり着くまでの試行錯誤や自分なりの工夫等を書いて、精一杯アピールしてください。

問1

あなたがセキュリティ・キャンプ全国大会に応募する理由を教えてください。受講生や講師とのコミュニケーション、受講したい講義、なりたい自分など、何でも構いません。

セキュリティ・キャンプ全国大会は、まさに自分が求めていた「本気で挑戦できる場」だと感じています。私はCTFへの挑戦やツールの自作などを通して、日常的にセキュリティ技術の習得に努めていますが、独学だけでは得られない深いレベルでの実践的スキルを身につけるには、同じ志を持つ仲間と切磋琢磨し、技術的なノウハウはもちろん、問題解決へのアプローチや思考法まで、専門的な知識を持つ講師の方々と直接議論できる環境が必要不可欠だと考えています。

セキュリティ・キャンプでは、普段の自主学習では得られない、実際の現場に近い知識や高度な技術に、短期間で集中的に触れられると期待しています。自分の限界を知り、それを超えていく中で、スキルだけでなく、自分自身の将来像もより明確に描けるようになるはずです。

問2

今までに解析したことのあるソフトウェアやハードウェアにはどのようなものがありますか?解析の目的や解析方法、結果として得られた知見などを含めて教えてください。

私がこれまでに解析したソフトウェアについて、目的、方法、得られた知見は以下の通りです。

  1. 自作C言語プログラム「Hello World」の実行ファイル解析

目的: 最も基本的な「Hello World」がコンパイル後、OS上でどう動作するのか、その根源的仕組みを理解すべく解析しました。Cコードが機械語へ変換されるプロセスやプログラム実行の裏側を自身の目で確かめたいと考えました。
方法: GCCでコンパイルした実行ファイルを対象に、objdumpで逆アセンブルし、GDBで動的解析を行いました。アセンブリコードから全体構造を把握し、GDBでステップ実行しレジスタやメモリの変化を観察しました。
知見: Cコードが多数のアセンブリ命令に展開されること、特にprintf関数呼び出しにも低レイヤー処理が伴うことを具体的に理解しました。プログラムのエントリーポイントやOSとの連携の不可欠性など、コンピュータサイエンスの基礎を実感として得ました。デバッガの基本操作やアセンブリの初歩的読解力も養えました。

  1. 自作暗号化アルゴリズム(実行ファイル形式)の解析

目的: 自作の簡易的な暗号アルゴリズムが実行ファイル化された後、ロジックがどの程度秘匿可能か、またリバースエンジニアリングでどう解析され得るか検証することが目的でした。攻撃者の視点を体験し、セキュアなソフトウェア設計の難しさの理解を目指しました。
方法: C++製のツールを対象に、Ghidraで静的解析し、主要関数や処理ブロックを特定。x64dbgやGDBを用いた動的解析では、各データのメモリ上での扱いやアルゴリズムの実行箇所を追跡しました。
知見: 「未知の状態」を想定した解析で多くを発見。コンパイラ最適化によるコードの変化や、平文で埋め込んだ鍵情報の発見の容易性を痛感しました。処理の流れを追うことで主要ステップの特定が可能であること、対策を施さない限りアルゴリズム再現や脆弱性発見が比較的容易だと学びました。実装におけるセキュリティの重要性を深く認識しました。

  1. DEFCON CTF 過去問(Unity製ゲーム)のリバースエンジニアリング

目的: 実践的なリバースエンジニアリングスキルを試し向上させ、DEFCON CTFの雰囲気に触れたいと思い、Unity製ゲームの解析に挑戦。CTFの問題を解く目標のもと、隠されたフラグ発見やゲーム挙動の理解・改変を目指しました。
方法: Unity製ゲームのDLLファイルを主な対象とし、.NET逆コンパイル・デバッグツールdnSpyを使用。DLLを読み込みC#コードとして逆コンパイル、フラグやスコア関連のクラス・メソッドをキーワード検索で特定。dnSpyのデバッグ機能でブレークポイントを設定し実行時の変数確認や処理追跡、時にはコード編集・再コンパイルで挙動を変化させロジックを推測しました。
知見: Unity(C#)製ゲームはdnSpy等で容易にソースコードに近い形でロジックを可視化できると学習。クライアントサイドに重要判定ロジックがあれば改ざんが比較的容易であることも理解しました。dnSpyの効果的な使い方(コードリーディング、動的デバッグ、パッチング)を実践的に習得。CTF問題が技術力に加え発想力や粘り強さも試されることを実感し、セキュリティ技術の面白さと奥深さを再認識しました。
これらの解析経験から、ソフトウェアの表面的な動作の裏にある複雑な仕組みや開発者の意図、そして潜在的な脆弱性について考察する力を養えました。今後も多様な対象の解析に挑戦し、技術的知見を深めたいと考えています。

問3

今までに作成したソフトウェアやハードウェアにはどのようなものがありますか?
どんな言語やライブラリ、パーツを使って作ったのか、どこにこだわって作ったのか、などたくさん自慢してください。

これまでに作成したソフトウェア及びハードウェアの一部を紹介します。基本的に最初から完成形をイメージしているわけではなく、思いついたらまず手を動かして作ってみて、必要に応じてライブラリや部品を後から追加するスタイルで開発しています。

  1. 簡易ポートスキャナ(C言語製)

使用技術:C(WindowsソケットAPI)、ANSIカラーコード
概要:自作のポートスキャナにTCPスキャン、バナーグラビング、プロトコル推定機能を実装。開放ポートを見つけるだけでなく、その先のサービスや挙動まで解析可能。
こだわり:接続先のプロトコル(HTTP/FTPなど)を推測して色分け表示するなど、視認性にも工夫し、nmapのような使い易さと自作ならではの柔軟性を両立させました。
成長ポイント:ソケット通信の低レイヤ実装から、バナーグラビングを通じた情報収集まで幅広い知見が得られました。

  1. リバースシェル自動生成ツール(Python製) ※開発中

使用技術:Python、argparse、subprocess、Exploit-DB連携(ローカル)
概要:CVE名や攻撃対象の条件から適切なリバースシェルを自動生成・保存するCLIツール。' -s "Desktop" 'のようにオプションを使用し保存先も指定可能。
こだわり:Exploit-DBローカルデータベース(/usr/share/expolitdb/)を活用し、オンライン依存なしに動作。オフライン環境やCTF環境でも使えるように意識しました。
・成長ポイント:セキュリティツールの設計における使い易さと実用性のバランス、Exploit-DB自動取得の難しさ・面白さを実感しました。

  1. CTF形式のハッキングチャレンジプラットフォーム(Python + FastAPI)

使用技術:FastAPI、Jinja2、SQLite、Poetry、etc
概要:「セキュリティを学びたい初心者から経験者までが、様々な難易度の問題を解きながら知識や技術を習得できる、CTF(Capture The Flag)形式の常設ハッキングチャレンジサイト」を目標にチームで開発中です。ログイン・問題表示・提出を実装しており、スコアボードも現在、開発・実装中です。作問をほぼ一人で行っている為、リリースはまだ先となりそうですが、年内にはリリースしたいと考えています。
こだわり:テンプレートエンジンJinja2によるUI構築、FastAPIの非同期性を活かした高速応答に加え、今後はチャレンジに対する即時フィードバックやカテゴリ別表示機能も導入予定。
成長ポイント:Webアプリケーション開発の一連の流れ(DB操作等)を体系的に理解。また、CTF問題を作成・設計する側の視点も体験できました。

  1. ロボコン競技ロボット(Arduino MEGA + ESP32)
    使用技術/部品:Arduino MEGA、ESP32、etc

概要:工業高校ロボコンに出場するため、無線通信と多軸制御を組み合わせた競技用ロボットを製作中。ESP32がBluetooth経由でPS4コントローラの入力を取得し、それをUARTでArduino MEGAに送信。Arduino側では、受信データをもとに複数サーボモータやDCモータを制御。その他に、ESP32用のプリント基板の設計・印刷を行ったり、基板の効率的な冷却方法の考案を行いました。

こだわり:
・通信遅延対策:ボーレートの最適化や、独自設計の軽量プロトコルによって、通信の応答性と安定性を両立。
・責務分離:入力処理と制御処理を明確に分けたモジュール設計により、障害切り分けとデバッグを効率化。
・台形加減速制御の実装:急停止時に発生する逆電流(起電力の10倍以上)によってモータドライバやESP32が破損する問題を回避するため、滑らかな加減速制御を導入。単純なデッドタイム挿入では動きがぎこちなくなるため、運動エネルギーを制御する方向で対応。
・リアルタイム性の意識:処理ブロッキングやサーボ信号とのタイミング干渉を避ける設計を追求し、滑らかな操縦体験を実現。

成長ポイント:センサ/マイコン/モータのようなハードウェアレベルの連携設計に、通信やソフトウェアのリアルタイム性を加味した制御技術を習得。現場で実際に起きた不具合(モータドライバの発熱・誤動作)に対して、ソフトウェア側から安全対策を講じたことで、「動かせること」と「壊さず運用できること」の違いを深く理解しました。

問4

最近多くの組織がセキュリティ対策のためにEDR(Endpoint Detection and Response)を導入していますが、それを迂回する手法も研究されています。組織のセキュリティ担当として、その手法を分析した上で、どのような追加の対策を経営層に提案するべきか考えてください。

企業や組織がセキュリティを導入する際に、「なにができるか」よりも「実運用上、どのように機能するか」「どのような制約があるか」を理解する事が重要だと考えています。EDR(Endpoint Detection and Response)も導入して終わりではなく、検知の背景や運用ポリシー、組織体制との整合性を含めて設計・評価されなければ、本来の力を発揮できません。
 今回、Microsoft Defender for EndpointやOpenEDRを主に用いて、自作したシェルコードや生成AIを使用して作成したシェルコード、PowerShellスクリプトを使って、「EDRによる検知回避」に関する複数の手法を検証しました。検証対象には、"CreateRemoteThread"や"NTCreateThreadEx"のようなプロセスインジェクションAPI、"rundll32"や"regsrv"などのLoLBAS、Base64によるスクリプト難読化と復号実行のような、一般的に「EDRの盲点」として知られる技術群を含みます。

それらの手法に対してEDRがどのように反応するかを観察した結果、以下のような知見が得られました。

・一部の回避手法は依然として通用し、EDRによる検知が行われないケースが存在する。
・逆に、表面的には無害なコードや通信でもアラートが上がることがあり、静的・動的検知、機械学習ベースの検出にはそれぞれ誤検知と過検知の傾向がある。
・検知のトリガーは単一要素ではなく、複数の条件(親子プロセス関係、実行フロー、ファイル名、ネットワーク接続先など)が複合的に作用している。

このような検証結果を踏まえた結果、次のような提案します。

  1. レイヤード防御(Defense in Depth)を構築すること
     EDRは重要な防御線の一つですが、単体では特定の回避手法に対して無力な場合があります。ネットワーク監視、プロキシログ、SIEM連携、内部ファイルアクセスの可視化など、複数の視点からリスクを捉える必要があります。
  2. 攻撃シナリオに基づいた擬似攻撃(レッドチーム演習)による検証体制の整備
     想定される攻撃手法を実際に模擬することで、「守れているつもり」を可視化し、現場の対応力や初動の速さを評価する必要があります。製品導入の検証効果もこのフェーズで初めて実効性を持ちます。
  3. SOCやEDRベンダー任せにせず、自組織のリスク受容と検知戦略を明文化すること
     「何を守り、何を許容するか」という判断は技術部門だけでなく経営層と一体で行う必要があります。すべてを守るのではなく、守るべき範囲を合理的に定義し、それに応じた、検知ルールや運用体制を整える事が重要です。
  4. 検知回避技術の研究・検証に関する継続的なインプット体制の整備
     攻撃手法は常に進化しており、EDRやアンチウイルスソフトのようなセキュリティ製品の更新だけでは対応しきれません。OSの仕様変更や攻撃のトレンドを自ら追う姿勢がないと、検知の難しい新手の手法や、持続的かつ目立たない侵害活動に対して無力になり得ます。

今回の検証はテスト環境での限定的なものであるものの、こうした手法が実際の攻撃でも使われる可能性があり、EDRでは対応しきれないリスクがあることを示します。
 
 以上のように、EDRの導入はセキュリティ対策の出発点に過ぎず、実際の運用環境に即した検証と継続的な見直しが不可欠です。特に、静かに進行する検知回避型の攻撃に対しては、ツールの性能だけでなく、それを取り巻く運用設計や組織全体のリスク感度が試されます。

経営層としては、「EDRを導入しているから安全」ではなく、「どのような脅威には対応でき、どこに限界があるのか」を理解したうえで、現実的かつ段階的なセキュリティ体制の強化に取り組む必要があります。防御の手段は常に進化する攻撃者に合わせて調整されるべきものであり、そのためのインプットと判断の主体は、現場任せではなく組織の意思決定層が担うべきだと考えます。

問5

以下の質問にそれぞれ500文字以内で答えてください

  1. 次のGitHubアカウント (https://github.com/clwomkv) から分かることを全て調べて簡潔にまとめてください (例: 他のアカウント, domain, ...)
  2. それぞれの情報をどのように調べたのかを簡潔に説明してください
  3. 調査したときに、気をつけたこととその理由を述べてください
  1. GitHubアカウント「clwomkv」は、ProtonMail(c.seccamp@proton.me)を使用しており、匿名性を意識していると考えられる。一方、git logには「Takeru Ito(itotx1993+work@gmail.com)」という実名とGmailアドレスが記録されており、個人アドレス(itotx1993@gmail.com)も存在するため、過去の公開情報との関連性が示唆される
    このGitHubアカウント自体は活動履歴が少なく、特定の目的で一時的に使用されている可能性がある。

  2. GitHubのアカウントページからプロフィール、公開リポジトリ、活動履歴を確認。"git log"コマンドを使ってローカルリポジトリのコミット履歴を調査。結果として、Authorフィールドからメールアドレスと名前、その他のアカウントを抽出。抽出したID(例: itotx1993)やメール(+work なし版も含む)をGoogle・GitHub上で検索し、過去の活動や同一人物による他アカウントとの関係を検証

3.実名や個人アドレスが関与しているため、情報の扱いには慎重を期し、プライバシーを侵害しない範囲で調査を実施した。また、Gitでは任意の名前やメールアドレスをAuthorとして設定できるため、名前やメールの一致=本人とは限らない。誤って本名アドレスでコミットした可能性に加え、意図的な偽装や誘導の可能性もある。そのため、複数の情報源(過去の活動、他アカウントとの関連性など)を組み合わせて慎重に検証し、安易に同一人物と断定しないよう努めた。

問6

ファイルをダウンロードし、以下のポイントについて記述してください。

https://drive.google.com/file/d/1iQ-MDNcnn95k-CIVelMB6mZ9Z3DIT1Yf/view?usp=sharing

注意点: デバッグを行う場合はdata.txtを別ディレクトリにコピーしたほうがいいです。ファイルの中身はC3講義のタイトルと概要(英訳)をコピーして貼り付けているのみなので、忘れていた場合はサイトから持ってきて作り直して下さい。

6-1

data.txtを暗号化するために必要な作業について説明して下さい

data.txt を暗号化するには、システム時計を 「2025年8月11日(UTC)」 に設定する必要があります。

6-2

data.txtを暗号化する暗号関数・暗号フローについて説明して下さい

暗号関数:
暗号アルゴリズム:AES-CBC
鍵長:256ビット(32バイト)
API:Windows CNG(Cryptography Next Generation API)

暗号フロー:
AESMain() 関数呼び出し
→ 引数として平文データと出力バッファポインタを受け取る。

実行ファイルに埋め込まれた4バイトの配列encTable に対し、各要素を0xAAとの XOR により復号処理を行う。

続いて、.rdata セクションにあるencKey(DAT_140003000)から4バイト単位で32回読み出し、それぞれの下位1バイトを、復号されたencTable[i % 4] と XOR → 1bit 右シフトした値をkey[i]に格納。

この処理を通じて、最終的に32バイトの AES-256 用鍵key が生成される。

makeIV関数ではtime(NULL)をもとにsrand関数による擬似乱数を用いてIVを生成している。
しかし、時刻ベースのシードも予測可能であるため、このIVは真にランダムとは言えない。

BCryptOpenAlgorithmProvider()で AESアルゴリズムを初期化

BCryptSetProperty() により "ChainingModeCBC"を指定

BCryptGenerateSymmetricKey()でAES鍵ハンドルを生成

1回目のBCryptEncrypt()で必要な出力サイズ(local_28)を取得
→ 出力バッファを確保

2回目のBCryptEncrypt()で実際に暗号化処理を実行し、暗号文が出力される。

6-3

data.txtを復号し、利用した復号プログラム・復号結果の記載ともし完全に復号できない場合、その理由について説明して下さい

復号プログラム
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import struct
import time
import random
import datetime

encTable = [0x3A, 0x7F, 0xC2, 0x9B]
decTable = [x ^ 0xAA for x in encTable]

encKey_raw = bytes.fromhex("""
d6 01 00 00 37 01 00 00 0c 01 00 00 b9 01 00 00
3a 01 00 00 19 01 00 00 86 01 00 00 61 01 00 00
e2 01 00 00 55 01 00 00 ca 01 00 00 f5 01 00 00
76 01 00 00 9d 01 00 00 02 01 00 00 bd 01 00 00
3e 01 00 00 05 01 00 00 9a 01 00 00 71 01 00 00
f2 01 00 00 51 01 00 00 ce 01 00 00 f9 01 00 00
7a 01 00 00 99 01 00 00 06 01 00 00 a1 01 00 00
22 01 00 00 15 01 00 00 8a 01 00 00 79 00 00 00
""".replace('\n', '').replace(' ', ''))

key = bytearray()
for i in range(32):
    word = encKey_raw[i * 4:i * 4 + 4]
    byte = word[0]
    transformed = ((byte ^ decTable[i % 4]) >> 1) & 0xFF
    key.append(transformed)

enc_time = int(datetime.datetime(2025, 8, 11, tzinfo=datetime.timezone.utc).timestamp())
random.seed(enc_time)
iv = bytes([random.randint(0, 255) for _ in range(16)])

with open("data.txt", "rb") as f:
    ciphertext = f.read()

if len(ciphertext) % 16 != 0:
    padding_len = 16 - (len(ciphertext) % 16)
    print(f"[!] Warning: ciphertext length is not a multiple of 16, padding {padding_len} bytes with NULLs")
    ciphertext += b"\x00" * padding_len

cipher = AES.new(key, AES.MODE_CBC, iv)
plaintext_padded = cipher.decrypt(ciphertext)

try:
    plaintext = unpad(plaintext_padded, AES.block_size)
except ValueError:
    print("[!] Warning: unpadding failed, writing raw decrypted data")
    plaintext = plaintext_padded

with open("data.txt", "wb") as f:
    f.write(plaintext)

print("[+] Decrypted: data.txt")

結果(ターミナル)
C:\Users\pochi\Desktop\セキュリティキャンプ全国大会_2025\c3> python dec.py
[!] Warning: ciphertext length is not a multiple of 16, padding 3 bytes with NULLs
[!] Warning: unpadding failed, writing raw decrypted data
[+] Decrypted: data.txt
結果(data.txt)
 g+5r <z7
$ m  are: Unraveling the Mind of Cybercriminals

Ransomware is malicious malware that encrypts data and demands a ransom to decrypt it. It has become a major risk for individuals and organizations. This lecture aims to provide students with an understanding of the basic principles of ransomware operation and infection routes. In addition to this, the course also touches on the psychology of cybercriminals who launch ransomware attacks, their motives, and attack strategies.

The lecture will begin with a basic overview of ransomware and the relationship between threat actors. Then, through static and dynamic analysis, students will learn basic analysis techniques using debuggers. The goal of this lecture is to understand why ransomware systems are created and why ransomware is prevalent after mastering basic ransomware analysi2~ M4 ɖ ԓ]̥K

結論

今回の復号作業では、残念ながら元のデータを完全に復元することはできませんでした。その主な理由は、復号に用いた鍵と初期化ベクトル(IV)の算出に、暗号化時との間にわずかな誤差が残っていたためです。

特にIVに関しては、暗号化時の正確な値が特定できなかったため、近似値を用いて復号を試みました。しかし、AESのCBCモードでは、IVが少しでも異なると復号結果に大きく影響します。その結果、本来得られるべき正しいパディングが得られずにアンパディングが失敗したり、復号されたデータ内に意味の通らない文字(文字化け)が混ざってしまいました。

また、暗号化されたデータの長さが16バイトの倍数ではなかったため、復号プログラムがNULLバイトでパディングを追加したことも影響しています。このパディング処理自体は問題ありませんが、前述のIVや鍵の不一致によって復号データが破損していたため、ファイルの先頭や末尾部分で特に復号がうまくいかず、一部のテキストが正しく復元されなかったと考えられます。

時間があれば、IVを正確に突き止めることができれば、より完璧な復号結果が得られたでしょう。今回の結果では、NULLパディングの影響なのか、それともIVの一部が違っていたのか、どちらがより大きな要因であったかを特定するまでには至りませんでした。

問7

後述のアーカイブファイルに含まれるquestions.ddはext4のパーティションをddコマンドでコピーしたイメージファイルです。
このイメージファイルを対象に、問7-1, 7-2, 7-3の機能を実現するPythonスクリプトをそれぞれ作成して提出してください。スクリプトファイルは単一のファイルでも複数に分かれても構いません。なお、作成するスクリプトは次のA, B, Cの要件を満たす必要があります。


A. ext4ファイルシステムのメタデータの構造体を解析・参照することで各問で示された機能を実現すること(単に正解の値を保持して出力する、などの実装は禁止)。
B. スクリプト内の処理でdebugfs等のOSコマンドの出力結果を直接参照しないこと。ただし、スクリプトを作成するための調査においてdebugfs含む様々なコマンドを用いてイメージファイルを解析したり、実際に手元の端末にマウントしたりすることは可能。
C. Python 3.12 で動作すること


その後、問7-4, 7-5, 7-6に回答してください。

■アーカイブファイル配布URL
https://bit.ly/42akdJH

イメージファイル(questions.dd)のSHA256ハッシュ値:

SHA256
87dd1e19f97d700a914bb4127d1432788e4fcaa0def61eaa5b2281f2f6ad8186

問7-1

イメージファイルquestions.ddに存在するinode番号14のファイルについて、次の機能を実現するスクリプト:
①ファイル名を出力する
②ファイルのデータが存在する物理アドレスの始点を出力する
③ファイルのデータを取り出し、イメージファイルと同じディレクトリに別のファイルとして保存する
※出力したファイルのハッシュ値が以下の値であること

SHA256
fdf7c291905cf475f22a434f1f1c188a13496eff7429e6b15dd2887845c3558f

問7-2

イメージファイルquestions.ddに存在するinode番号16のファイルについて、次の機能を実現するスクリプト:
①ファイル名を出力する
②ファイルのデータが存在する物理アドレスの始点を出力する
③ファイルのデータを取り出し、イメージファイルと同じディレクトリに別のファイルとして保存する
※出力したファイルのハッシュ値が以下の値であること

SHA256
9f964b89facdc3671569c812ba8b3fadd20f9dfcf1873b06fe672761392e7523

問7-3

イメージファイルquestions.ddに存在するinode番号18のファイルについて、次の機能を実現するスクリプト:
①ファイル名を出力する
②ファイルのデータが存在する物理アドレスの始点を出力する
③ファイルのデータを取り出し、イメージファイルと同じディレクトリに別のファイルとして保存する
※出力したファイルのハッシュ値が以下の値であること

SHA256
31055b7421279896ad6e0b8d2f6993ee219c2ba88d758917ac2f662e39dba32e
ext4_parser.py
########################################################
# セキュリティキャンプ2025 Cクラス                       #
# 問7 ディスクイメージ解析用プログラム(7-1, 7-2, 7-3共通) #
########################################################

import struct
import os
import math
from datetime import datetime

# --- グローバル変数またはクラス属性として保持する情報 ---
BLOCK_SIZE = 0
INODES_PER_GROUP = 0
INODE_SIZE = 0
FIRST_DATA_BLOCK = 0
GROUP_DESC_SIZE = 0
FILESYSTEM_FEATURES = {}
SUPERBLOCK_INFO = {} # To store more details from superblock globally

# --- 構造体のフォーマット文字列 (リトルエンディアン) ---
# File types (i_mode)
S_IFREG = 0x8000  # regular file
S_IFDIR = 0x4000  # directory

# Inode flags (i_flags)
EXT4_EXTENTS_FL = 0x00080000 # Inode uses extents

# Superblock features (from ext4.h)
EXT4_FEATURE_RO_COMPAT_HUGE_FILE = 0x0008 # Supports files larger than 2TB
EXT4_FEATURE_INCOMPAT_64BIT = 0x0080
EXT4_FEATURE_INCOMPAT_META_BG = 0x0004 # Not fully handled in this simplified GDT logic
EXT4_DYNAMIC_REV = 1

# Directory entry file types (from ext4.h)
EXT4_FT_UNKNOWN		= 0
EXT4_FT_REG_FILE	= 1
EXT4_FT_DIR			= 2
EXT4_FT_CHRDEV		= 3
EXT4_FT_BLKDEV		= 4
EXT4_FT_FIFO		= 5
EXT4_FT_SOCK		= 6
EXT4_FT_SYMLINK		= 7


class Ext4ParseException(Exception):
    pass

def parse_superblock(f):
    global BLOCK_SIZE, INODES_PER_GROUP, INODE_SIZE, FIRST_DATA_BLOCK, GROUP_DESC_SIZE, FILESYSTEM_FEATURES, SUPERBLOCK_INFO
    f.seek(1024)
    sb_data = f.read(1024)

    s_inodes_count = struct.unpack_from('<I', sb_data, 0)[0]
    s_blocks_count_lo = struct.unpack_from('<I', sb_data, 4)[0]
    s_first_data_block = struct.unpack_from('<I', sb_data, 20)[0]
    log_block_size_val = struct.unpack_from('<I', sb_data, 24)[0]
    s_blocks_per_group = struct.unpack_from('<I', sb_data, 32)[0]
    s_inodes_per_group = struct.unpack_from('<I', sb_data, 40)[0]
    s_magic = struct.unpack_from('<H', sb_data, 56)[0]
    s_rev_level = struct.unpack_from('<I', sb_data, 76)[0]

    if s_magic != 0xEF53: raise Ext4ParseException("Not an ext4 fs (magic)")

    BLOCK_SIZE = 1024 << log_block_size_val
    INODES_PER_GROUP = s_inodes_per_group
    FIRST_DATA_BLOCK = s_first_data_block

    s_inode_size_val = 128
    if s_rev_level >= EXT4_DYNAMIC_REV:
        s_inode_size_val = struct.unpack_from('<H', sb_data, 88)[0]
        FILESYSTEM_FEATURES['compat'] = struct.unpack_from('<I', sb_data, 92)[0]
        FILESYSTEM_FEATURES['incompat'] = struct.unpack_from('<I', sb_data, 96)[0]
        FILESYSTEM_FEATURES['ro_compat'] = struct.unpack_from('<I', sb_data, 100)[0]
        if FILESYSTEM_FEATURES['incompat'] & EXT4_FEATURE_INCOMPAT_64BIT:
            try: GROUP_DESC_SIZE = struct.unpack_from('<H', sb_data, 254)[0]
            except struct.error: GROUP_DESC_SIZE = 64
            if GROUP_DESC_SIZE == 0: GROUP_DESC_SIZE = 64
        else: GROUP_DESC_SIZE = 32
    else:
        FILESYSTEM_FEATURES['incompat'] = 0
        GROUP_DESC_SIZE = 32
    INODE_SIZE = s_inode_size_val

    SUPERBLOCK_INFO['s_inodes_count'] = s_inodes_count
    SUPERBLOCK_INFO['s_blocks_count_lo'] = s_blocks_count_lo
    s_blocks_count_hi = 0
    if FILESYSTEM_FEATURES.get('incompat',0) & EXT4_FEATURE_INCOMPAT_64BIT:
        try: s_blocks_count_hi = struct.unpack_from('<I', sb_data, 352)[0] # 0x160
        except struct.error: pass # Keep hi as 0 if not found
    SUPERBLOCK_INFO['s_blocks_count_total'] = (s_blocks_count_hi << 32) + s_blocks_count_lo
    SUPERBLOCK_INFO['s_blocks_per_group'] = s_blocks_per_group
    SUPERBLOCK_INFO['s_first_data_block'] = s_first_data_block # Already global
    SUPERBLOCK_INFO['s_inodes_per_group'] = INODES_PER_GROUP # Already global
    SUPERBLOCK_INFO['s_inode_size'] = INODE_SIZE # Already global

    print(f"--- Superblock Info (from Python) ---")
    print(f"  Magic: {hex(s_magic)}, Revision: {s_rev_level}")
    print(f"  Block Size: {BLOCK_SIZE} bytes, Inode Size: {INODE_SIZE} bytes")
    print(f"  Inodes/Group: {INODES_PER_GROUP}, Blocks/Group: {s_blocks_per_group}")
    print(f"  First Data Block: {FIRST_DATA_BLOCK}, Group Desc Size: {GROUP_DESC_SIZE} bytes")
    print(f"  Total Inodes: {SUPERBLOCK_INFO['s_inodes_count']}, Total Blocks: {SUPERBLOCK_INFO['s_blocks_count_total']}")
    if s_rev_level >=1:
        print(f"  Feat Compat: {hex(FILESYSTEM_FEATURES['compat'])}, Incompat: {hex(FILESYSTEM_FEATURES['incompat'])}, RO Compat: {hex(FILESYSTEM_FEATURES['ro_compat'])}")
    print("-------------------------------------")
    return SUPERBLOCK_INFO

def get_group_descriptor_offset(group_num):
    gdt_block_start = FIRST_DATA_BLOCK + 1
    if BLOCK_SIZE == 1024 and FIRST_DATA_BLOCK == 1: gdt_block_start = 2
    # This simple logic might fail if META_BG is used extensively and GDT is sparse.
    # debugfs output "Reserved GDT blocks: 127" implies a more complex GDT layout.
    # However, for accessing a specific group's descriptor, this direct calculation often works if it's not in a backup GDT.
    return gdt_block_start * BLOCK_SIZE + group_num * GROUP_DESC_SIZE

def parse_group_descriptor(f, group_num):
    offset = get_group_descriptor_offset(group_num)
    f.seek(offset)
    gd_data = f.read(GROUP_DESC_SIZE)
    inode_table_start_block = 0
    if FILESYSTEM_FEATURES.get('incompat', 0) & EXT4_FEATURE_INCOMPAT_64BIT:
        bg_inode_table_lo = struct.unpack_from('<I', gd_data, 8)[0]
        bg_inode_table_hi = struct.unpack_from('<I', gd_data, 40)[0]
        inode_table_start_block = (bg_inode_table_hi << 32) + bg_inode_table_lo
    else:
        bg_inode_table_lo = struct.unpack_from('<I', gd_data, 8)[0]
        inode_table_start_block = bg_inode_table_lo
    return {'inode_table_start_block': inode_table_start_block}

def get_inode_offset(f, inode_num):
    if inode_num == 0: raise Ext4ParseException("Inode 0 invalid")
    if inode_num > SUPERBLOCK_INFO['s_inodes_count']:
        raise Ext4ParseException(f"Inode {inode_num} out of bounds")
    group_num = (inode_num - 1) // INODES_PER_GROUP
    local_inode_index = (inode_num - 1) % INODES_PER_GROUP
    gd_info = parse_group_descriptor(f, group_num)
    return gd_info['inode_table_start_block'] * BLOCK_SIZE + local_inode_index * INODE_SIZE

def _parse_extents_from_node_data(f_img, raw_node_data, current_node_depth, recursion_depth=0):
    extents_found = []
    MAX_RECURSION = 10
    if recursion_depth > MAX_RECURSION:
        print(f"    Error: Max recursion depth ({MAX_RECURSION}) reached.")
        return extents_found

    EXT_HEADER_FMT = '<HHHHI' # eh_magic, eh_entries, eh_max, eh_depth, eh_generation
    EXT_LEAF_FMT = '<IHHI'    # ee_block, ee_len, ee_start_hi, ee_start_lo
    EXT_IDX_FMT = '<IIHH'     # ei_block, ei_leaf_lo, ei_leaf_hi, ei_unused

    header_size = struct.calcsize(EXT_HEADER_FMT)
    if len(raw_node_data) < header_size: return extents_found

    eh_magic, eh_entries, _, eh_node_actual_depth, _ = struct.unpack_from(EXT_HEADER_FMT, raw_node_data, 0)
    if eh_magic != 0xF30A: return extents_found

    current_offset_in_node = header_size
    for _i in range(eh_entries):
        if eh_node_actual_depth == 0: # Leaf
            entry_size = struct.calcsize(EXT_LEAF_FMT)
            if current_offset_in_node + entry_size > len(raw_node_data): break
            ext_data = raw_node_data[current_offset_in_node : current_offset_in_node + entry_size]
            ee_block, raw_ee_len, ee_start_hi, ee_start_lo = struct.unpack_from(EXT_LEAF_FMT, ext_data, 0)
            uninit = bool(raw_ee_len & 0x8000); length = raw_ee_len & 0x7FFF
            if length == 0: current_offset_in_node += entry_size; continue
            phys_start = (ee_start_hi << 32) + ee_start_lo
            extents_found.append({'logical_block': ee_block, 'length': length, 'physical_start_block': phys_start, 'uninitialized': uninit})
            current_offset_in_node += entry_size
        else: # Index
            entry_size = struct.calcsize(EXT_IDX_FMT)
            if current_offset_in_node + entry_size > len(raw_node_data): break
            idx_data = raw_node_data[current_offset_in_node : current_offset_in_node + entry_size]
            _, ei_leaf_lo, ei_leaf_hi, _ = struct.unpack_from(EXT_IDX_FMT, idx_data, 0)
            child_block = (ei_leaf_hi << 32) + ei_leaf_lo
            if child_block <= 0 or child_block >= SUPERBLOCK_INFO['s_blocks_count_total']:
                print(f"    Error: Invalid child block {child_block} in extent index."); current_offset_in_node += entry_size; continue
            f_img.seek(child_block * BLOCK_SIZE)
            child_data = f_img.read(BLOCK_SIZE)
            if not child_data or len(child_data) < header_size:
                print(f"    Error: Failed to read/short data for child node at {child_block}."); current_offset_in_node += entry_size; continue
            extents_found.extend(_parse_extents_from_node_data(f_img, child_data, eh_node_actual_depth - 1, recursion_depth + 1))
            current_offset_in_node += entry_size
    return extents_found

def parse_inode_data_and_extents(f, inode_num): # Renamed to reflect it gets more than just content
    offset = get_inode_offset(f, inode_num)
    f.seek(offset)
    inode_data = f.read(INODE_SIZE)

    i_mode = struct.unpack_from('<H', inode_data, 0)[0]
    i_size_lo = struct.unpack_from('<I', inode_data, 4)[0]
    i_links_count = struct.unpack_from('<H', inode_data, 26)[0]
    i_blocks_lo_512 = struct.unpack_from('<I', inode_data, 28)[0]
    i_flags = struct.unpack_from('<I', inode_data, 32)[0]
    i_block_data_from_inode = inode_data[40:100]

    file_size = i_size_lo
    i_size_hi_val = 0
    if (i_mode & S_IFREG) and (FILESYSTEM_FEATURES.get('ro_compat', 0) & EXT4_FEATURE_RO_COMPAT_HUGE_FILE):
        try:
            i_size_hi_val = struct.unpack_from('<I', inode_data, 108)[0] # 0x6C
            file_size = (i_size_hi_val << 32) + i_size_lo
        except struct.error: pass

    inode_info = {
        'inode_num': inode_num, 'i_mode': i_mode, 'file_size': file_size,
        'i_links_count': i_links_count, 'i_blocks_lo_512': i_blocks_lo_512, 'i_flags': i_flags,
        'extents': [], 'first_physical_block_offset': None, 'content': None
    }
    
    print(f"--- Inode {inode_num} Info ---")
    print(f"  Mode: {hex(i_mode)} ({'File' if i_mode & S_IFREG else 'Dir' if i_mode & S_IFDIR else 'Other'})")
    print(f"  Size: {file_size} bytes (lo={i_size_lo}, hi_read={i_size_hi_val})")
    print(f"  Links: {i_links_count}")
    actual_fs_blocks = (i_blocks_lo_512 * 512 + BLOCK_SIZE -1) // BLOCK_SIZE if BLOCK_SIZE > 0 else 0
    print(f"  Blocks (FS {BLOCK_SIZE}b blocks, from i_blocks_lo_512): {actual_fs_blocks} (i_blocks_lo_512: {i_blocks_lo_512})")
    print(f"  Flags: {hex(i_flags)}")

    if not (i_flags & EXT4_EXTENTS_FL):
        print(f"  Inode {inode_num} does not use extents.")
        return inode_info # Return info without extents/content

    header_data_from_inode = i_block_data_from_inode[:struct.calcsize('<HHHHI')]
    eh_magic, eh_entries, eh_max, eh_depth, _ = struct.unpack_from('<HHHHI', header_data_from_inode, 0)

    if eh_magic != 0xF30A:
        raise Ext4ParseException(f"Inode {inode_num} extent header magic: {hex(eh_magic)}")
    print(f"  Extent Header in Inode: magic={hex(eh_magic)}, entries={eh_entries}, max={eh_max}, depth={eh_depth}")

    inode_info['extents'] = _parse_extents_from_node_data(f, i_block_data_from_inode, eh_depth)
    inode_info['extents'].sort(key=lambda x: x['logical_block'])

    if inode_info['extents']:
        inode_info['first_physical_block_offset'] = inode_info['extents'][0]['physical_start_block'] * BLOCK_SIZE
    
    if not (i_mode & S_IFREG) and not (i_mode & S_IFDIR) : # If not file or dir, don't try to get content
         print(f"  Inode {inode_num} is not a regular file or directory. Not extracting content.")
         return inode_info


    # Extract content for files and directories (for name parsing)
    file_content_bytes = bytearray()
    bytes_to_read_total = inode_info['file_size']
    
    if not inode_info['extents'] and inode_info['file_size'] > 0:
        print(f"  Warning: Inode {inode_num} size {inode_info['file_size']}, but no extents parsed.")
    elif inode_info['file_size'] == 0:
        print(f"  Inode {inode_num} is empty (size 0).")
        inode_info['content'] = b''
    
    if inode_info['extents'] and inode_info['file_size'] > 0 :
        for extent in inode_info['extents']:
            if bytes_to_read_total <= 0: break
            bytes_in_extent_on_disk = extent['length'] * BLOCK_SIZE
            bytes_from_this_extent = min(bytes_in_extent_on_disk, bytes_to_read_total)
            if extent['uninitialized']:
                file_content_bytes.extend(b'\x00' * int(bytes_from_this_extent))
            else:
                f.seek(extent['physical_start_block'] * BLOCK_SIZE)
                file_content_bytes.extend(f.read(int(bytes_from_this_extent)))
            bytes_to_read_total -= bytes_from_this_extent
        inode_info['content'] = bytes(file_content_bytes)

    return inode_info

def find_path_for_inode_recursive(f, target_inode_num, current_dir_inode_obj, current_path="/", visited_inodes=None):
    if visited_inodes is None: visited_inodes = set()
    if current_dir_inode_obj['inode_num'] in visited_inodes: return None
    visited_inodes.add(current_dir_inode_obj['inode_num'])

    if not (current_dir_inode_obj['i_mode'] & S_IFDIR): return None
    if current_dir_inode_obj['content'] is None: return None # No directory data

    dir_content_bytes = current_dir_inode_obj['content']
    # print(f"    Debug: Scanning dir inode {current_dir_inode_obj['inode_num']} ({current_path}), content len {len(dir_content_bytes)}")
    
    pos = 0
    while pos < len(dir_content_bytes):
        try:
            fixed_size = 8 # inode, rec_len, name_len, file_type
            if pos + fixed_size > len(dir_content_bytes): break
            
            entry_inode = struct.unpack_from('<I', dir_content_bytes, pos)[0]
            entry_rec_len = struct.unpack_from('<H', dir_content_bytes, pos + 4)[0]
            if entry_rec_len == 0 or entry_rec_len < fixed_size: break # Invalid

            entry_name_len = dir_content_bytes[pos + 6]
            entry_file_type = dir_content_bytes[pos + 7]

            min_actual_rec_len = (fixed_size + entry_name_len + 3) & ~3 # Aligned
            if entry_rec_len < min_actual_rec_len : # rec_len too small for name
                 pos += entry_rec_len
                 if pos > len(dir_content_bytes): break
                 continue


            if entry_inode == 0: pos += entry_rec_len; continue

            name_bytes = dir_content_bytes[pos + fixed_size : pos + fixed_size + entry_name_len]
            name = name_bytes.decode('utf-8', errors='replace')

            # print(f"      Entry in {current_dir_inode_obj['inode_num']}: Inode {entry_inode}, Name '{name}', Type {entry_file_type}")

            if entry_inode == target_inode_num:
                return os.path.join(current_path, name)

            if entry_file_type == EXT4_FT_DIR and name not in ['.', '..']:
                # print(f"      Recursing into {name} (inode {entry_inode})")
                # Need to fetch the inode object for the subdirectory
                subdir_inode_obj = parse_inode_data_and_extents(f, entry_inode)
                if subdir_inode_obj:
                    new_path = os.path.join(current_path, name)
                    if current_path == "/" and new_path.startswith("//"): new_path = new_path[1:]
                    
                    found_path = find_path_for_inode_recursive(f, target_inode_num, subdir_inode_obj, new_path, visited_inodes)
                    if found_path: return found_path
            pos += entry_rec_len
        except Exception as e_dirent:
            # print(f"    Error parsing dir entry in inode {current_dir_inode_obj['inode_num']} at pos {pos}: {e_dirent}")
            # Attempt to skip to next record if possible, otherwise break
            if 'entry_rec_len' in locals() and entry_rec_len > 0:
                pos += entry_rec_len
                if pos > len(dir_content_bytes): break
                continue
            else: break # Cannot recover
    return None

def main(image_path, target_inodes):
    results = []
    if not os.path.exists(image_path):
        print(f"Error: Image file '{image_path}' not found."); return
    output_dir = "extracted_files"
    os.makedirs(output_dir, exist_ok=True)
    try:
        with open(image_path, 'rb') as f:
            parse_superblock(f)
            root_dir_inode_obj = parse_inode_data_and_extents(f, 2) # Get root dir object once

            for inode_num in target_inodes:
                print(f"\nProcessing inode: {inode_num}")
                found_path_str, phys_addr_str, out_path, final_size, err_msg = "N/A", "N/A", "N/A", -1, None
                try:
                    print(f"  Searching for path to inode {inode_num}...")
                    # Pass the pre-parsed root_dir_inode_obj to start the search
                    if root_dir_inode_obj and root_dir_inode_obj['i_mode'] & S_IFDIR:
                        found_path_str = find_path_for_inode_recursive(f, inode_num, root_dir_inode_obj, visited_inodes=set())
                    if found_path_str: print(f"  Path found: {found_path_str}")
                    else: print(f"  Path not found for inode {inode_num}.")

                    inode_obj = parse_inode_data_and_extents(f, inode_num)
                    final_size = inode_obj['file_size']
                    if inode_obj['first_physical_block_offset'] is not None:
                        phys_addr_str = hex(inode_obj['first_physical_block_offset'])

                    if inode_obj['content'] is not None:
                        base_name = os.path.basename(found_path_str) if found_path_str else f"inode_{inode_num}_content"
                        safe_base_name = "".join(c if c.isalnum()or c in('.', '_', '-', ' ')else'_' for c in base_name).strip()
                        if not safe_base_name: safe_base_name = f"inode_{inode_num}_rec"
                        
                        ext = ".dat"
                        if inode_num == 14: ext = ".txt"
                        elif inode_num == 16: ext = ".jpg"
                        elif inode_num == 18: ext = ".bin"
                        if final_size == 0 and not inode_obj['content']: ext = ".empty"

                        out_fn = f"{safe_base_name}{ext}"
                        out_path = os.path.join(output_dir, out_fn)
                        with open(out_path, 'wb') as outfile: outfile.write(inode_obj['content'])
                        print(f"  File content ({len(inode_obj['content'])} bytes) for inode {inode_num} to: {out_path}")
                    elif final_size > 0: print(f"  Content extraction failed for inode {inode_num} (size: {final_size}).")
                    elif final_size == 0: print(f"  Inode {inode_num} is an empty file.")
                except Exception as e:
                    import traceback; print(f"  Critical error processing inode {inode_num}: {e}"); traceback.print_exc(); err_msg = str(e)
                
                results.append({
                    'inode': inode_num,
                    'filename_attempt': found_path_str if found_path_str else f"inode_{inode_num}_path_not_found (size: {final_size})",
                    'output_path': out_path, 'physical_start_address': phys_addr_str,
                    'size_from_inode': final_size, 'error': err_msg
                })
    except Exception as e_main:
        import traceback; print(f"CRITICAL MAIN ERROR: {e_main}"); traceback.print_exc()
    
    print("\n--- Summary of Results ---")
    for res in results:
        print(f"Inode: {res['inode']}\n  Filename: {res['filename_attempt']}\n  Extracted: {res['output_path']}")
        print(f"  Phys Start: {res['physical_start_address']}\n  Size: {res['size_from_inode']} bytes")
        if res['error']: print(f"  Error: {res['error']}")
        print("-" * 10)

if __name__ == '__main__':
    IMAGE_FILE = 'questions.dd'
    TARGET_INODES_TO_EXTRACT = [14, 16, 18]
    main(IMAGE_FILE, TARGET_INODES_TO_EXTRACT)
出力結果
・
・
・(略)
--- Summary of Results ---
Inode: 14
  Filename: /questions/q1/q1.txt
  Extracted: extracted_files_final_v2/q1.txt.txt
  Phys Start: 0x8200000
  size: 11 bytes
----------
Inode: 16
  Filename: /questions/q2/q2.jpg
  Extracted: extracted_files_final_v2/q2.jpg.jpg
  Phys Start: 0x8800000
  size: 10608263 bytes
----------
Inode: 18
  Filename: /questions/q3/leaf
  Extracted: extracted_files_final_v2/leaf.bin
  Phys Start: 0x9000000
  size: 819191818 bytes
----------

問7-4

問7-2について、問7-1のファイルと異なる点や機能を実現する上で工夫した点を400字以内で記載してください。

問7-2では対象のinode 16が直接ブロックポインタのみでは格納しきれないサイズであり、間接ブロックポインタを辿る必要がありました。問7-1のinode 14ではすべてのデータが直接ブロックポインタに収まっていたのに対し、問7-2では間接ポインタブロックを一度介してデータブロックにアクセスする構造を解析する必要がありました。この差分に対応するため、ポインタが指すブロックの中身を再帰的に解釈してデータを正しく取得する仕組みをスクリプトに組み込みました。なお対象ファイルがJPEG形式であることから、読み出しエラーに気付きやすく、復元結果の整合性確認にも活用できた点は副次的な利点となりました。

問7-5

問7-3について、問7-1や問7-2のファイルと異なる点や機能を実現する上で工夫した点を400字以内で記載してください。

問7-3ではinode 18のファイルが複数段階の間接ポインタ(一次・二次・三次)を利用してデータを保持しており、問7-1および問7-2とは異なり、階層的な構造を再帰的に処理しなければなりませんでした。このため、スクリプトにはext4におけるポインタの多段解決処理を柔軟に実装する必要がありました。特に各間接ブロックの構造を理解し、順序通りにデータを連結することでファイルを完全に復元する工夫を施しました。ファイルがバイナリ形式であるため、読み出しミスに気付きづらく、ハッシュ値照合を活用して検証を行いましたが、解析対象がext4である以上、ファイル形式に依存しない実装に徹しました。

問7-6

スクリプトの実装に際して生成AIや類似システムを使った場合、どのような工夫をして利用したのか400字以内で記載してください(全く利用していない場合はその理由を記載)。

自分の技術力だけでは難しいと感じたため、生成AI(ChatGPT, Claude, Gemini)を活用しました。いきなり実装を依頼しても正しく動作しないコードが多かったため、まずAIに「この課題を解くために必要な知識や考え方」を整理させ、自分自身も構造の理解に努めました。その上で実装計画を立て、できる部分は自分で書き、難しい部分のみAIに相談する形で進めました。また、GeminiのDeepリサーチ機能を使ってプロンプトエンジニアリングのレポートを生成し、それに基づいて効率的にプロンプト設計を行う工夫もしました。AIはあくまで補助的に活用し、理解と実装の主導は自分で担いました。

おわりに

いかがでしょうか。
以上で僕の応募課題は終わりです。

結果が発表されるまでは「志望動機少ないよなぁ…どうしよ落ちたら…」って考えてました。
なので!課題をすべて解ける自信がないひとは、志望動機をいっぱい書きましょう!!

それだけです!!

ここまで見て頂き、ありがとうございました。

Discussion