diff --git a/.github/workflows/build-live-iso.yml b/.github/workflows/build-live-iso.yml new file mode 100644 index 0000000..b124dd8 --- /dev/null +++ b/.github/workflows/build-live-iso.yml @@ -0,0 +1,346 @@ +name: Build Live ISO draft release +run-name: Build Live ISO for ${{ inputs.tag || 'site-takeover-live-v0.1.0-rc1' }} + +on: + # workflow_dispatch is shown only after this file reaches the default branch. + # This exact feature branch trigger bootstraps the first draft release. + push: + branches: + - "feat/live-usb-b2r" + workflow_dispatch: + inputs: + tag: + description: "Draft prerelease tag, for example site-takeover-v0.1.0" + required: true + default: "site-takeover-live-v0.1.0-rc1" + type: string + +permissions: {} + +concurrency: + group: live-iso-${{ inputs.tag || 'site-takeover-live-v0.1.0-rc1' }} + cancel-in-progress: false + +jobs: + checks: + name: Required checks + uses: ./.github/workflows/test.yml + permissions: + contents: read + + build: + name: Build release assets + needs: checks + runs-on: ubuntu-24.04 + timeout-minutes: 180 + permissions: + contents: read + + env: + ISO_PATH: labs/site-takeover/live/dist/site-takeover-live-amd64.iso + SHA256_PATH: labs/site-takeover/live/dist/site-takeover-live-amd64.iso.sha256 + BOOT_REPORT_PATH: labs/site-takeover/live/dist/site-takeover-live-amd64.boot.txt + + steps: + - name: Check out selected source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + + - name: Validate tag and selected commit + env: + RELEASE_TAG: ${{ inputs.tag || 'site-takeover-live-v0.1.0-rc1' }} + SELECTED_SHA: ${{ github.sha }} + shell: bash + run: | + set -Eeuo pipefail + + if [[ ! "${RELEASE_TAG}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then + printf 'Invalid tag: use 1-128 ASCII letters, digits, dots, underscores, or hyphens.\n' >&2 + exit 1 + fi + + actual_sha="$(git rev-parse HEAD)" + [[ "${actual_sha}" == "${SELECTED_SHA}" ]] || { + printf 'Checked-out commit %s does not match selected commit %s.\n' "${actual_sha}" "${SELECTED_SHA}" >&2 + exit 1 + } + + if git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}"; then + tag_sha="$(git rev-list -n 1 "refs/tags/${RELEASE_TAG}")" + [[ "${tag_sha}" == "${SELECTED_SHA}" ]] || { + printf 'Existing tag %s points to %s, not selected commit %s.\n' \ + "${RELEASE_TAG}" "${tag_sha}" "${SELECTED_SHA}" >&2 + exit 1 + } + fi + + - name: Build in privileged Debian 13 + shell: bash + run: | + set -Eeuo pipefail + docker run --rm --privileged \ + --volume "${GITHUB_WORKSPACE}:/workspace" \ + --workdir /workspace \ + debian:13-slim \ + bash -Eeuo pipefail -c ' + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + live-build \ + rsync \ + wget \ + xorriso + rm -rf /var/lib/apt/lists/* + git config --global --add safe.directory /workspace + ./labs/site-takeover/live/build.sh + ' + + - name: Verify release assets + shell: bash + run: | + set -Eeuo pipefail + [[ -s "${ISO_PATH}" ]] + [[ -s "${SHA256_PATH}" ]] + [[ -s "${BOOT_REPORT_PATH}" ]] + + iso_dir="$(dirname "${ISO_PATH}")" + iso_name="$(basename "${ISO_PATH}")" + checksum_name="$(basename "${SHA256_PATH}")" + ( + cd "${iso_dir}" + sha256sum --check "${checksum_name}" + ) + + checksum_target="$(awk 'NR == 1 { print $2 }' "${SHA256_PATH}")" + [[ "${checksum_target}" == "${iso_name}" ]] || { + printf 'Checksum asset names %s, expected %s.\n' "${checksum_target}" "${iso_name}" >&2 + exit 1 + } + + - name: Transfer verified release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: site-takeover-live-release-assets + path: | + labs/site-takeover/live/dist/site-takeover-live-amd64.iso + labs/site-takeover/live/dist/site-takeover-live-amd64.iso.sha256 + labs/site-takeover/live/dist/site-takeover-live-amd64.boot.txt + if-no-files-found: error + compression-level: 0 + overwrite: true + retention-days: 1 + + release: + name: Update draft prerelease + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: write + + env: + ISO_PATH: labs/site-takeover/live/dist/site-takeover-live-amd64.iso + SHA256_PATH: labs/site-takeover/live/dist/site-takeover-live-amd64.iso.sha256 + BOOT_REPORT_PATH: labs/site-takeover/live/dist/site-takeover-live-amd64.boot.txt + OPERATOR_KIT_PATH: labs/site-takeover/live/dist/site-takeover-operator-kit.zip + OPERATOR_KIT_SHA256_PATH: labs/site-takeover/live/dist/site-takeover-operator-kit.zip.sha256 + + steps: + - name: Check out selected source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + + - name: Download verified release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: site-takeover-live-release-assets + path: labs/site-takeover/live/dist + + - name: Package operator methods from selected source + env: + SELECTED_SHA: ${{ github.sha }} + shell: bash + run: | + set -Eeuo pipefail + + rm -f -- "${OPERATOR_KIT_PATH}" "${OPERATOR_KIT_SHA256_PATH}" + git archive \ + --format=zip \ + --output="${OPERATOR_KIT_PATH}" \ + "${SELECTED_SHA}" \ + -- \ + METHODS.md \ + README.md \ + PROJECT_CONSTITUTION.md \ + DESIGN.md \ + TASK_CONTRACT.md \ + labs/site-takeover/README.md \ + labs/site-takeover/VERIFICATION.md \ + labs/site-takeover/live/README.md \ + labs/site-takeover/operator/company-bootstrap.ps1 \ + labs/site-takeover/operator/COMPANY-SETUP.md \ + labs/site-takeover/operator/USB.md \ + labs/site-takeover/operator/DAY-OF.md \ + labs/site-takeover/operator/KALI-PREFLIGHT.md \ + labs/site-takeover/operator/ROOT-BONUS.md + + ( + cd "$(dirname "${OPERATOR_KIT_PATH}")" + sha256sum "$(basename "${OPERATOR_KIT_PATH}")" \ + > "$(basename "${OPERATOR_KIT_SHA256_PATH}")" + sha256sum --check "$(basename "${OPERATOR_KIT_SHA256_PATH}")" + ) + + - name: Revalidate tag and release assets + env: + RELEASE_TAG: ${{ inputs.tag || 'site-takeover-live-v0.1.0-rc1' }} + SELECTED_SHA: ${{ github.sha }} + shell: bash + run: | + set -Eeuo pipefail + + if [[ ! "${RELEASE_TAG}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then + printf 'Invalid release tag.\n' >&2 + exit 1 + fi + + actual_sha="$(git rev-parse HEAD)" + [[ "${actual_sha}" == "${SELECTED_SHA}" ]] || { + printf 'Checked-out commit %s does not match selected commit %s.\n' "${actual_sha}" "${SELECTED_SHA}" >&2 + exit 1 + } + + if git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}"; then + tag_sha="$(git rev-list -n 1 "refs/tags/${RELEASE_TAG}")" + [[ "${tag_sha}" == "${SELECTED_SHA}" ]] || { + printf 'Existing tag %s moved to %s during the build.\n' "${RELEASE_TAG}" "${tag_sha}" >&2 + exit 1 + } + fi + + [[ -s "${ISO_PATH}" ]] + [[ -s "${SHA256_PATH}" ]] + [[ -s "${BOOT_REPORT_PATH}" ]] + [[ -s "${OPERATOR_KIT_PATH}" ]] + [[ -s "${OPERATOR_KIT_SHA256_PATH}" ]] + ( + cd "$(dirname "${ISO_PATH}")" + sha256sum --check "$(basename "${SHA256_PATH}")" + sha256sum --check "$(basename "${OPERATOR_KIT_SHA256_PATH}")" + ) + + - name: Create or update draft prerelease + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag || 'site-takeover-live-v0.1.0-rc1' }} + SELECTED_SHA: ${{ github.sha }} + shell: bash + run: | + set -Eeuo pipefail + + notes_file="$(mktemp)" + trap 'rm -f -- "${notes_file}"' EXIT + cat > "${notes_file}" </dev/null + )"; then + IFS=$'\t' read -r is_draft target_commitish <<< "${release_info}" + [[ "${is_draft}" == "true" ]] || { + printf 'Refusing to modify non-draft release %s.\n' "${RELEASE_TAG}" >&2 + exit 1 + } + + if ! git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}"; then + [[ "${target_commitish}" == "${SELECTED_SHA}" ]] || { + printf 'Draft release %s targets %s, not selected commit %s.\n' \ + "${RELEASE_TAG}" "${target_commitish}" "${SELECTED_SHA}" >&2 + exit 1 + } + fi + + gh release edit "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --draft \ + --prerelease \ + --title "Site Takeover Live ISO ${RELEASE_TAG}" \ + --notes-file "${notes_file}" + else + gh release create "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --target "${SELECTED_SHA}" \ + --draft \ + --prerelease \ + --title "Site Takeover Live ISO ${RELEASE_TAG}" \ + --notes-file "${notes_file}" + fi + + release_assets=( + "${ISO_PATH}" + "${SHA256_PATH}" + "${BOOT_REPORT_PATH}" + "${OPERATOR_KIT_PATH}" + "${OPERATOR_KIT_SHA256_PATH}" + "labs/site-takeover/operator/COMPANY-SETUP.md" + ) + + upload_state="$( + gh release view "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json isDraft \ + --jq '.isDraft' + )" + [[ "${upload_state}" == "true" ]] || { + printf 'Refusing to upload to non-draft release %s.\n' "${RELEASE_TAG}" >&2 + exit 1 + } + + gh release upload "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber \ + "${release_assets[@]}" + + final_state="$( + gh release view "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json isDraft,isPrerelease \ + --jq '[.isDraft, .isPrerelease] | @tsv' + )" + [[ "${final_state}" == $'true\ttrue' ]] || { + printf 'Release safety check failed: %s\n' "${final_state}" >&2 + exit 1 + } + + released_assets="$( + gh release view "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json assets \ + --jq '.assets[].name' + )" + for release_asset in "${release_assets[@]}"; do + required_asset="$(basename "${release_asset}")" + grep -Fxq "${required_asset}" <<< "${released_assets}" || { + printf 'Draft release is missing asset %s.\n' "${required_asset}" >&2 + exit 1 + } + done diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..61a6408 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,87 @@ +name: Test + +on: + pull_request: + push: + branches: + - "**" + workflow_call: + +permissions: + contents: read + +jobs: + checks: + name: Static and unit checks + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run Node tests + run: npm --prefix labs/site-takeover test + + - name: Check Bash syntax + shell: bash + run: | + set -Eeuo pipefail + checked=0 + while IFS= read -r -d '' file; do + if head -n 1 "${file}" | grep -Eq '^#!.*(/|env )(ba)?sh([[:space:]]|$)'; then + bash -n "${file}" + checked=$((checked + 1)) + fi + done < <(find labs/site-takeover -type f -print0) + ((checked > 0)) + printf 'Checked %d shell scripts.\n' "${checked}" + + - name: Check PHP syntax + shell: bash + run: | + set -Eeuo pipefail + command -v php >/dev/null + checked=0 + while IFS= read -r -d '' file; do + php -l "${file}" + checked=$((checked + 1)) + done < <(find labs/site-takeover -type f -name '*.php' -print0) + ((checked > 0)) + printf 'Checked %d PHP files.\n' "${checked}" + + - name: Parse PowerShell scripts + shell: pwsh + run: | + $failed = $false + $files = @(Get-ChildItem -LiteralPath 'labs/site-takeover' -Recurse -File -Filter '*.ps1') + if ($files.Count -eq 0) { + throw 'No PowerShell scripts were found.' + } + + foreach ($file in $files) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $file.FullName, + [ref]$tokens, + [ref]$errors + ) | Out-Null + + foreach ($parseError in @($errors)) { + Write-Error "$($file.FullName):$($parseError.Extent.StartLineNumber): $($parseError.Message)" + $failed = $true + } + } + + if ($failed) { + exit 1 + } + + Write-Host "Checked $($files.Count) PowerShell scripts." + + - name: Run safe Windows bootstrap self-test + shell: pwsh + run: ./labs/site-takeover/operator/company-bootstrap.ps1 -SelfTest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3108be --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +labs/site-takeover/live/dist/ +labs/site-takeover/live/.work/ diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..1d5ab57 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,63 @@ +# Site Takeover Lab Design Contract + +## Design read + +参加者が最初に見るのは「教材画面」ではなく、実在しそうな小さな店のWebサイトと明確なミッションである。サイトは温かく日常的に、ガイドは実験ノートのように簡潔で落ち着いた見た目にし、両者を混同させない。 + +## Dials + +- Design variance: 5 +- Motion intensity: 1 +- Visual density: 4 + +## Target site identity + +- 架空の小さな店の公式サイトとし、企業物語や固有の人物設定を攻略条件にしない。 +- 背景は生成り、本文は濃い墨色、アクセントは深い赤茶を使う。 +- トップページの「本日のお知らせ」を、書き換え結果が最も目立つ場所に置く。 +- 外部フォント、CDN、外部画像を使わず、直結LANだけで完全表示する。 +- 脆弱性名やCTF用語を公開ページ上に表示しない。 + +## Guide identity + +- 白地、黒文字、青緑の単一アクセントで、対象サイトと視覚的に分離する。 +- 常に「いま分かっていること」「次に知りたいこと」「結果から言えること」の順で表示する。 +- コマンドは、参加者が目的を選択した後にだけ表示する。 +- ヒントは3段階とし、最終段階でも実行前に目的を一文で答えさせる。 +- 正解音や派手な演出ではなく、サイトの実変化を成功演出の中心にする。 + +## Target console identity + +- ターゲット画面は教材UIにせず、運営者向けの状態表示だけにする。 +- Linuxの初期コンソールで日本語フォントが欠けても読めるよう、`REMOVE USB`、`DISK DETECTED - STOP`、`EXERCISE READY`の固定ASCII見出しを大きく表示し、紙の当日手順に日本語の対応を載せる。 +- ディスクが見える状態では警告だけを出し、参加者向けURLや成功表示を出さない。 +- 保守モードの操作はローカルコンソールに限定し、参加者向けガイドへ混ぜない。 + +## Interaction states + +- Start: 日常語だけでミッション、禁止事項、成功条件を示す。 +- Choice: 次に知りたいことを2つから選ばせる。 +- Command: 選択した目的に対応する操作と、見るべき出力箇所を示す。 +- Evaluate: 貼り付けた出力をブラウザ内だけで判定する。 +- Retry: 認識できない場合は失敗扱いにせず、確認する行を具体的に示す。 +- Hint 1: 観察対象だけを示す。 +- Hint 2: 操作の種類と入力場所を示す。 +- Hint 3: 実行可能な例を示すが、チーム名などの可変部分は本人に入力させる。 +- Success: 侵入経路を4枚の因果カードで振り返る。 +- Bonus: 非公開メモと管理者権限を別章として明示する。 + +## Accessibility and device fit + +- KaliのFirefox ESR/Firefoxで動作し、JavaScript無効時にも基本手順を読める。 +- 1366×768、1280×720、360px幅で横スクロールを発生させない。 +- 色だけで成功、警告、ヒント段階を表現しない。 +- キーボードだけで選択、展開、貼り付け、判定を完了できる。 +- アニメーションは必須にせず、`prefers-reduced-motion`を尊重する。 + +## Copy rules + +- 初出のIT用語は、先に日常語を置いてから括弧内で名称を示す。 +- 「ポートをスキャンする」ではなく「相手PCで外から使える入口を調べる」と書く。 +- 「RCEに成功」ではなく「Webへの入力で相手PCに命令を実行させられた」と書く。 +- rootは「PC全体を変更できる管理者権限」と併記する。 +- 失敗時に参加者を責める文言を使わない。 diff --git a/METHODS.md b/METHODS.md new file mode 100644 index 0000000..eead7fe --- /dev/null +++ b/METHODS.md @@ -0,0 +1,255 @@ +# Site Takeover Live USB: 全手法 + +この文書は、現行の `Site Takeover Live USB` を準備・実施・復旧する運営担当向けの +入口であり、必須ゴールの完全解答でもある。参加者にはこの文書を見せず、標的内の +`http://10.13.37.10/start/` を使ってもらう。 + +GitHubを正本とする。詳細手順は役割ごとの文書へ分け、このページから全て辿れるように +する。旧 `Yamamoto Mfg. OVA` の攻略方法とは混ぜない。 + +## 対象範囲と安全範囲 + +ここにある入力やコマンドを使ってよいのは、運営担当の許可の下で次の構成を作った +ときだけである。 + +- 標的: 専用Live USBからRAM起動したノートPC +- 標的IP: `10.13.37.10` +- 攻撃側: 直結Ethernet上のKali 1台 +- 接続先: `http://10.13.37.10/` と、その同一ホスト上のガイドだけ +- 演習中: Wi-Fi、会社LAN、VPN、DNS、デフォルトルートを使わない + +会社のIP、インターネット上のIP、第三者のWebサイトへ、この文書のスキャンや入力を +試してはいけない。標的が `EXERCISE READY` でない、Kaliにデフォルト経路が残る、 +または物理ディスク検査が失敗した場合は中止する。安全検査を無効化して続行しない。 + +## 会社での準備・構築 + +正本は +[`COMPANY-SETUP.md`](labs/site-takeover/operator/COMPANY-SETUP.md) +である。会社のWindows、個人テザリング、USBだけで進められる。 + +方法は二つに分ける。 + +1. ソースを変えない場合は、privateのdraft prereleaseから検証済みISO、SHA-256、 + BIOS/UEFI記録を取得する。GitやCodexは不要である。 +2. ソースを変える場合だけリポジトリをcloneし、契約文書を読んでからCodexで修正し、 + テスト、commit、push、GitHub Actionsによる再ビルドへ進む。 + +どちらでも、ISOが存在するだけでは当日用にならない。SHA-256、VM、USB全容量、 +標的実機、Kali隔離を別々の合格ゲートとして扱う。現在の実測と未完ゲートは +[`VERIFICATION.md`](labs/site-takeover/VERIFICATION.md) +に記録する。 + +Debian 13上でローカル再ビルドする場合だけ、 +[`live/README.md`](labs/site-takeover/live/README.md) +を使う。Windows上で未検証の代替ビルドを作らない。 + +## USBの検査と書き込み + +破壊操作を含む正本は +[`USB.md`](labs/site-takeover/operator/USB.md) +である。画面に出たディスク番号だけで判断せず、メーカー名、容量、BusType、接続を +照合して物理USBを特定する。 + +1. 警告歴のあるBUFFALO USBは、H2testwの `all available space` を + `Write + Verify` し、エラー0件の場合だけ使う。 +2. ISOと `.iso.sha256` を照合する。 +3. Rufusで対象USBを再確認し、ISOをDDイメージモードで書く。 +4. 書き込み後はWindowsのファイル置き場として併用しない。 + +H2testwで1件でもエラーが出た、容量が一致しない、または対象ディスクを一意に確認 +できない場合は、そのUSBへ書き込まず別のUSBを使う。 + +## 標的ノートの起動 + +BitLocker、UEFI、起動、当日の運営、保守、Windowsへの戻し方の正本は +[`DAY-OF.md`](labs/site-takeover/operator/DAY-OF.md) +である。 + +起動前にBitLocker回復キーを本人が確認し、必要なら保護を一時停止する。Windowsは +消去も分割もせず、UEFIで内蔵SSDを無効化する。Live OSは +`toram nopersistence` でRAMへ展開する。 + +標的画面の順番は固定する。 + +```text +REMOVE USB + ↓ Live USBを物理的に抜く +CONNECT LAN + ↓ ここで初めてKaliとのLANケーブルを挿す +EXERCISE READY +``` + +`BLOCKED`、物理ディスク名、またはUSB取り外し失敗が表示されたら演習を始めない。 +参加者が操作するのはKaliだけで、標的ノートのコンソールは運営担当だけが触る。 + +## Kaliの準備と隔離 + +Kali実機とWindows上のKali VMの両方について、正本は +[`KALI-PREFLIGHT.md`](labs/site-takeover/operator/KALI-PREFLIGHT.md) +である。 + +- Kali実機: Wi-FiとVPNを切り、デフォルト経路がないことを確認してから直結する。 +- Kali VM: VirtualBoxのアダプター1だけを、標的へつないだ物理Ethernetへ + ブリッジする。NAT、ホストオンリー、アダプター2〜4は無効にする。 +- DHCPで付く攻撃側アドレスは `10.13.37.100`〜`10.13.37.150` である。 +- DHCPが失敗した場合だけ `10.13.37.100/24` の固定IPを使い、終了時に削除する。 + +直結後、少なくとも次を確認する。 + +```bash +ip route get 10.13.37.10 +ip -4 route show default +command -v firefox nmap curl +curl --noproxy '*' --fail http://10.13.37.10/ +curl --noproxy '*' --fail http://10.13.37.10/start/ +``` + +`ip -4 route show default` は何も返さなければ合格である。 + +## 必須攻略フローの完全解答 + +ここからは運営担当用のネタバレである。参加者には最初からコピーさせず、ローカル +ガイドで「何を知りたいか」を選んでもらう。50分時点で進行が止まっている場合だけ、 +該当段階までヒントを開く。 + +### 1. Webの入口を確認する + +Kaliのターミナルで実行する。 + +```bash +nmap -sV 10.13.37.10 +``` + +`80/tcp open` が見えれば、HTTPのWebサイトが入口である。Firefoxで次を開き、 +「本日のお知らせ」の初期表示を観察する。 + +```text +http://10.13.37.10/ +``` + +### 2. 公開された手掛かりから管理ページを見つける + +ブラウザで `http://10.13.37.10/robots.txt` を開くか、Kaliで実行する。 + +```bash +curl http://10.13.37.10/robots.txt +``` + +`Disallow: /staff/` が見つかる。これはパスワードではなく、誰でも読める公開情報に +管理用パスを書いてしまった状態である。次の順で開く。 + +```text +http://10.13.37.10/staff/ +http://10.13.37.10/staff/network-check.php +``` + +### 3. Web入力からOS命令が動くことを確認する + +以降の `127.0.0.1; ...` はKaliのターミナルではなく、Webページの「接続先」欄へ +入力する。 + +```text +127.0.0.1; whoami +``` + +結果に `www-data` が出れば成功である。アプリは本来 `ping` の接続先だけを受け取る +つもりだが、入力を文字列連結してシェルへ渡している。半角セミコロン `;` により +`ping` の後へ別の命令を追加できた。 + +### 4. 現在地とトップページの元データを特定する + +同じ「接続先」欄へ、一つずつ入力して結果を観察する。 + +```text +127.0.0.1; pwd +127.0.0.1; ls -la ../.. +127.0.0.1; cat ../../data/announcement.txt +``` + +観察点は次の三つである。 + +- 現在地は `/srv/shop-site/public/staff` +- `public` と同じ階層に `data` がある +- `announcement.txt` の内容がトップページの「本日のお知らせ」と一致する + +### 5. 自分のチーム名へ書き換える + +`TEAM AO` を24文字以内のチーム名へ置き換え、Webの「接続先」欄へ入力する。 +使用できるのは日本語、英数字、空白、ハイフン、アンダースコアである。 + +```text +127.0.0.1; printf '%s\n' 'SECURITY TEST SUCCESS: TEAM AO' > ../../data/announcement.txt +``` + +トップページを再読み込みし、「本日のお知らせ」が +`SECURITY TEST SUCCESS: TEAM AO` へ変われば必須ゴール完了である。 + +### 6. 丸写しで終わらせない確認 + +参加者自身の言葉で、次の三点を説明してもらう。 + +1. 文字を入力した場所はKaliだが、追加した命令が動いた場所は標的ノートである。 +2. 最初に得たのは `www-data` というWebサーバ用の限定利用者である。 +3. 画面を直接編集したのではなく、トップページが読む `announcement.txt` を + 上書きしたため表示が変わった。 + +答えられない項目だけ、`whoami`、`pwd`、ファイル表示の観察へ戻る。 + +## rootボーナス + +必須ゴールと上の説明を終えた場合だけ実施する。完全な入力と説明の正本は +[`ROOT-BONUS.md`](labs/site-takeover/operator/ROOT-BONUS.md) +である。 + +流れは、まずWebの「接続先」欄で `127.0.0.1; sudo -l` を使い、`www-data` に +誤って許可された保守コマンドを見つける。その保守コマンドの引数処理を利用し、 +管理者だけが読める `/root/root-proof.txt` を表示する。これはこのラボ専用に +意図して入れた二つ目の脆弱性であり、実運用へコピーしてはいけない。 + +非公開ファイルだけを先に試す場合は、Webの「接続先」欄へ次を入力する。 + +```text +127.0.0.1; cat ../../private/manager-note.txt +``` + +## リセットと復旧 + +再起動が唯一の信頼できる初期化である。お知らせだけを戻すcleanupや永続領域は +使わない。正確な復旧手順は +[`DAY-OF.md`](labs/site-takeover/operator/DAY-OF.md) +に従う。 + +1. 標的とKaliのLANケーブルを抜く。 +2. 標的コンソールで `sudo poweroff` を実行し、完全に電源が切れるまで待つ。 +3. Live USBが抜けていることを確認する。 +4. UEFIで内蔵SSDを再び有効化し、Windowsを起動する。 +5. WindowsとBitLocker保護が通常状態へ戻ったことを確認する。 +6. Kaliで一時的な固定IPを作った場合は削除し、借用元の接続設定へ戻す。 + +次回は同じLive USBから起動すれば、お知らせ、root証拠、ガイド進捗、Codex認証を +含まない初期状態へ戻る。 + +## 検証とトラブル対応 + +実測済みの証拠と、物理環境で未完のゲートは +[`VERIFICATION.md`](labs/site-takeover/VERIFICATION.md) +を正本とする。GitHub Actions成功、物理USB、対象ノート、借用Kaliは別々に判定し、 +一つの成功を別の証拠として代用しない。 + +詰まり方ごとの入口は次のとおり。 + +| 状態 | 確認先 | 判断 | +|---|---|---| +| ISOを取得できない | [`COMPANY-SETUP.md`](labs/site-takeover/operator/COMPANY-SETUP.md) | private GitHub認証、個人テザリング、draft prereleaseを確認 | +| USB検査でエラー | [`USB.md`](labs/site-takeover/operator/USB.md) | そのUSBを不採用にする | +| `BLOCKED` またはSSDが見える | [`DAY-OF.md`](labs/site-takeover/operator/DAY-OF.md) | 演習を始めず、電源終了してUEFIから確認 | +| KaliへIPが付かない | [`KALI-PREFLIGHT.md`](labs/site-takeover/operator/KALI-PREFLIGHT.md) | 直結NICを確認し、必要時だけ固定IP | +| 店もガイドも開かない | [`DAY-OF.md`](labs/site-takeover/operator/DAY-OF.md) | `EXERCISE READY`、リンクランプ、経路を順に確認 | +| 必須ゴール後にrootへ進む | [`ROOT-BONUS.md`](labs/site-takeover/operator/ROOT-BONUS.md) | 必須の因果説明を終えた場合だけ進む | +| ソース変更後の品質を確認 | [`VERIFICATION.md`](labs/site-takeover/VERIFICATION.md) | 自動検査、VM、実機を別ゲートで記録 | + +Webサービス自体が壊れた場合だけ、LANを抜き、`sudo lab-mode maintenance` で +演習サービスを止めてから個人テザリングと `codex-maintenance` を使う。修理後は +Wi-Fiを切り、直結LANだけへ戻し、`sudo lab-mode exercise` の全preflightが +`PASS`になるまで参加者を再開させない。 diff --git a/PROJECT_CONSTITUTION.md b/PROJECT_CONSTITUTION.md new file mode 100644 index 0000000..9b13f15 --- /dev/null +++ b/PROJECT_CONSTITUTION.md @@ -0,0 +1,58 @@ +# Site Takeover Lab Constitution + +## Thesis + +IT未経験者が、目の前の実機で動くWebサイトを自分の手で書き換え、「Webへの入力が別のPC上のOS命令につながる」ことを90分以内に説明できる安全な体験を作る。 + +## Moment of satisfaction + +参加者がKali側のブラウザを再読み込みし、トップページに自分のチーム名が表示された瞬間に、目の前の実機へ侵入できたと理解する。 + +## One-sentence spec + +Windowsを残した標的ノートを専用Live USBからRAM起動し、Kaliとの直結LAN上でWeb入力から限定ユーザーとして命令を実行してトップページを書き換え、非公開ファイルと管理者権限を追加課題として体験できる、再起動で初期化されるラボを提供する。 + +## Mechanism + +1. 最初に、誰でも意味を理解できる普通のWebサイトと「ページを書き換える」というゴールだけを見せる。 +2. 参加者は次に知りたいことを選び、ガイドは選択後に必要な操作を提示する。 +3. 意図的に脆弱な診断入力から、Webサーバ上で限定されたOS命令を実行できるようにする。 +4. 書き換え可能な「お知らせ」データをトップページへ反映し、侵入結果を視覚化する。 +5. 実行結果を貼り付けるガイドが観察点と理由を返し、丸写しではなく因果関係を残す。 +6. Live OSを読み取り専用イメージからRAMへ展開し、再起動ごとに同じ初期状態を作る。 +7. USBと内蔵SSDが見えない場合だけ脆弱サービスを起動し、直結Ethernet以外を遮断する。 + +## Architecture grammar + +- `labs/site-takeover/target` は、意図した脆弱性と侵入後に観察できるデータだけを所有する。 +- `labs/site-takeover/guide` は、参加者の選択、出力判定、段階的な説明だけを所有する。 +- `labs/site-takeover/live` は、Debian Live ISO、起動時初期化、物理ディスク拒否だけを所有する。 +- `labs/site-takeover/operator` は、演習モード、保守モード、当日確認だけを所有する。 +- `.github/workflows` は、検査とLive ISOの再現ビルドだけを所有する。 +- GitHubは構築資材の正本とし、演習中の実行依存にはしない。 +- USBはLive OSの起動だけを担い、RAM展開後は標的ノートから抜く。 + +## Non-goals + +- 山本製作所、Drupal 7.57、既存OVAの改変または物理PCへの変換 +- 内蔵SSDの分割、デュアルブート、Windowsの消去、Debianの内蔵SSDへの導入 +- DVWAのように脆弱性名を先に選ばせる教材 +- インターネット、社内LAN、第三者システムへの攻撃 +- ブルートフォース、マルウェア、永続化、回避技術の演習 +- root取得を全参加者の必須条件にすること +- 手順書どおりのコマンド入力だけで完走させること + +## Failure modes + +1. コマンドの意味を選ぶ場面がなく、参加者がコピー作業として終える。 +2. USB不良、USB起動、内蔵SSDの露出、Kali差分でWebサイトを見る前に時間を失う。 + +## Observable acceptance + +- USB起動と取り外し後、2台を直結してから5分以内にKaliでサイトとガイドを開ける。 +- 完全初心者がヒントを利用すれば30分以内にトップページを書き換えられる。 +- 参加者が終了時に「入力がシェルへ渡った」「最初はWeb用の限定ユーザーだった」「ページの元データを書き換えた」を自分の言葉で説明できる。 +- 演習モードでは物理ディスク、Wi-Fi、DNS、デフォルトルート、Codex認証情報が存在しない。 +- 再起動後に改ざん、root証拠、ガイド進捗、Codex認証が残らない。 +- private GitHubの手動ワークフローからISO、SHA-256、BIOS/UEFI起動記録を再生成できる。 +- 借用Kaliと自分のKaliの両方で、事前確認表が合格する。 diff --git a/README.md b/README.md index 242f5c5..b23a1f8 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,55 @@ -# Yamamoto Mfg. CTF +# tgtsec training targets -社内プチ CTF。**山本製作所(Yamamoto Manufacturing Co., Ltd.)** という架空の中小企業の、長年放置された Web サーバーを攻略します。 +このprivateリポジトリは、許可された隔離環境だけで使うセキュリティ教材の正本です。 -> 配布: takes-security-hacking-lab +## Current: Site Takeover Live USB -## 概要 +技術部会向けの現行教材は [`labs/site-takeover`](labs/site-takeover/README.md) です。 +準備、USB、起動、Kali、完全攻略、rootボーナス、復旧、検証を含む全手法は +[`METHODS.md`](METHODS.md)から辿れます。 -| 項目 | 内容 | -|---|---| -| 形式 | Boot2Root (1台のVMを完全制覇) | -| 難度 | 中級初心者(semi-beginner) | -| 想定時間 | 2〜4時間(初心者) / 30〜60分(慣れてる人) | -| カテゴリ | Web exploitation, Linux 権限昇格 | -| フラグ形式 | `TSHL{...}` × 3個 | +- 標的ノートのWindowsと内蔵SSDを消去しない +- 専用USBから`toram nopersistence`でRAM起動する +- USBと物理ディスクが見えない場合だけ脆弱Webサービスを開始する +- Kaliと直結したEthernetだけで完結する +- 初心者の必須ゴールは、店のトップページを自分たちの名前へ書き換えること +- 非公開メモと管理者権限は任意のボーナス -## 始め方 +会社のWindowsだけで準備する場合も、最初に [`METHODS.md`](METHODS.md) で +全体と安全範囲を確認してから、 +[`COMPANY-SETUP.md`](labs/site-takeover/operator/COMPANY-SETUP.md)を読みます。ISOを生成しただけでは当日用にせず、VM、USB全容量、対象ノートの順に検証します。 -1. [Releases](https://github.com/skjshr/tgtsec/releases) から OVA をダウンロード -2. [`SETUP.md`](SETUP.md) に従ってインポート + ネットワーク設定 -3. 攻撃用VMから `nmap` でターゲット発見 → CTF開始 +```text +会社Windows + 個人テザリング + -> private draft prereleaseから検証済みISOを取得 + -> USB全容量検査 + -> Rufusで書き込み + -> 対象ノートで実機検証 -## このリポジトリにあるもの +ソースを変える場合だけ + -> private GitHubへpush + -> Actionsで新しいLive ISOをビルド +``` -- [`README.md`](README.md) — このファイル -- [`SETUP.md`](SETUP.md) — **プレイヤー向け** VirtualBox インポート手順 -- [`PRIMER.md`](PRIMER.md) — **CTF初参加者はまずこれ**。基礎用語・心構え・ツール導入 -- [`WALKTHROUGH-BEGINNER.md`](WALKTHROUGH-BEGINNER.md) — 段階的ヒント集(完全ネタバレなし) -- [`TOOLS-CHEATSHEET.md`](TOOLS-CHEATSHEET.md) — よく使うコマンド一覧 -- [`DISTRIBUTION.md`](DISTRIBUTION.md) — **出題者向け** OVA配布手順 +設計上の約束は [`PROJECT_CONSTITUTION.md`](PROJECT_CONSTITUTION.md)、 +見た目と教材体験は [`DESIGN.md`](DESIGN.md)、現在の完了条件は +[`TASK_CONTRACT.md`](TASK_CONTRACT.md)に分けています。 -## ルール +## Legacy: Yamamoto Mfg. OVA -- 攻撃対象は提供されたVMのみ。ホストOSや他ネットワークは攻撃禁止 -- 詰まったら `WALKTHROUGH-BEGINNER.md` を1段階ずつ開く -- ブルートフォース系を**長時間回さない**(意図された解法では不要です) +山本製作所を題材にした旧OVA教材は、現行Live USB教材とは別物です。既存OVAを使う場合だけ、次の旧文書を参照します。 -楽しんで! +- [`SETUP.md`](SETUP.md) — VirtualBoxへのOVA導入 +- [`PRIMER.md`](PRIMER.md) — 初参加者向け基礎 +- [`WALKTHROUGH-BEGINNER.md`](WALKTHROUGH-BEGINNER.md) — 段階的ヒント +- [`TOOLS-CHEATSHEET.md`](TOOLS-CHEATSHEET.md) — コマンド一覧 +- [`DISTRIBUTION.md`](DISTRIBUTION.md) — OVA配布手順 + +旧OVAを物理SSDへ変換したり、現行Live USBの代用品にしたりしません。 + +## 共通ルール + +- 攻撃対象は、その回に運営担当が指定した標的だけ +- 会社LAN、インターネット、第三者のIPを調査しない +- 認証情報、個人情報、会社情報を標的へ置かない +- 失敗した安全検査を無効化して続行しない diff --git a/TASK_CONTRACT.md b/TASK_CONTRACT.md new file mode 100644 index 0000000..016b888 --- /dev/null +++ b/TASK_CONTRACT.md @@ -0,0 +1,64 @@ +# Task Contract: Live USB Web Site Takeover Lab v2 + +## Goal + +1〜3人のIT完全初心者が90分の技術部会で、Live USBから起動した実機Webサーバへ直結LANで侵入し、トップページを自分のチーム名へ書き換え、その因果を説明できる教材を作る。 + +## Inputs + +- Target: Windowsとデータを残す8GB RAMノートPC、Ethernetあり、UEFIで内蔵SSDを無効化でき、BitLocker回復手段を本人が確認済み +- Attacker: Kali入り借用PC、またはKali VMを動かす自分のPC +- Network: 2台をLANケーブルで直結 +- Session: 90分、参加者1〜3人、IT/ターミナル未経験 +- Media: 64GB BUFFALO USB、現状はexFATの`Full Repair Needed`。全容量検査でエラー0件の場合だけ採用 +- Build station: 標的ノートのWindows、個人テザリング、GitHub CLI、Codex CLI + +## Removals and rejected alternatives + +- 山本製作所/Drupal/既存OVAはv2の素材にせず、比較用の旧教材として凍結する。 +- DVWAは脆弱性名を先に選ぶ構造が今回の発見体験と合わないため採用しない。 +- 受付PCやサイネージ設定は、CLI実機との説明差が増えるため採用しない。 +- ターゲットのデスクトップGUIは、故障点と再構築時間を増やすため採用しない。 +- Debian DVDによるクリーンインストール、SSD分割、デュアルブートはWindows保護と再構築時間に反するため採用しない。 +- Ventoyによる複数ISO運用は、起動時の選択と書き換え可能なデータ領域を増やすため採用しない。 +- GitHub Pagesは借用PCと回線に依存するため、演習中の必須経路にしない。 +- 侵入後のcleanupスクリプトや永続領域は信頼できる初期化にならないため、リセット手段にしない。 +- root取得は初回の必須ゴールにしない。 + +## Constraints + +- 演習サービスはターゲットの直結Ethernetでだけ公開する。 +- Live OSは`toram nopersistence`で起動し、USBを抜いた後にだけ演習を開始する。 +- 内蔵SSDまたは外付けディスクが見える場合は、脆弱サービスをfail closedで起動しない。 +- 演習中はWi-Fi、DNS、デフォルトルート、外部接続を無効にする。 +- Codex CLIは保守モードだけで使用し、認証情報と履歴を再起動後へ残さない。 +- 脆弱性は教材コード内に限定し、第三者製品の古い脆弱版へ依存しない。 +- ターゲットへ個人情報、会社情報、本物の資格情報を置かない。 +- 参加者向けガイドにoperator用の完全解答、root秘密、保守認証情報を含めない。 +- GitHub、Codex、SSHの認証情報をLive ISOへ含めない。 +- USBへ書き込む前に全容量の読み書き検査を行い、ISOのSHA-256を照合する。 + +## Done criteria + +- ターゲットサイト、段階型ガイド、意図したコマンドインジェクションを実装する。 +- 限定ユーザーでトップページの「お知らせ」を書き換えられる。 +- 限定ユーザーでダミーの非公開メモを読める。 +- ボーナス経路で管理者権限の証拠を取得できる。 +- Debian Live ISOを一つの構築コマンドとGitHub Actionsの両方から再現できる。 +- ディスクなし環境では起動し、物理ディスクが見える環境では演習サービスを拒否する。 +- exercise/maintenanceのモード切替と、exercise状態の外部経路遮断を自動確認できる。 +- 借用Kali向けにUSB不要の事前確認表を用意する。 +- Firefoxで参加者フローをE2E確認し、デスクトップと狭幅のスクリーンショットを残す。 +- 会社のWindowsだけでclone、Codex修正、Actionsビルド、ISO取得、USB作成を完了できる。 +- USB検査、Live起動、USB取り外し、終了後の再起動をoperator文書へ残す。 +- 構築、USB、起動、Kali、必須攻略、root、復旧、検証の全手法をprivate GitHub上の一つの索引から辿れ、運営用の必須攻略だけは完全解答として残す。 +- privateブランチ、draft PR、検証済みdraft prereleaseをGitHubへ作る。 + +## Surprise ledger + +- USBの主用途をOVA保管やOS再インストールから、読み取り専用Live OSの起動へ変更する。 +- 演習ガイドもターゲットPC内から配信し、当日のインターネット依存をなくす。 +- v2は既存リポジトリ内の独立した`labs/site-takeover`として追加し、旧教材を上書きしない。 +- 標的ノートのWindowsを会社での開発・USB作成環境として残す。 +- workflow_dispatchがdefault branchへ入る前は表示されないため、未mergeの初回だけ`feat/live-usb-b2r`へのpushでdraft prereleaseビルドを起動する。 +- 運営用の完全解答はGitHubへ置くが、参加者向けガイドには混ぜず、発見と因果説明の体験を維持する。 diff --git a/labs/site-takeover/README.md b/labs/site-takeover/README.md new file mode 100644 index 0000000..ed2e998 --- /dev/null +++ b/labs/site-takeover/README.md @@ -0,0 +1,32 @@ +# Site Takeover Lab + +IT完全初心者向けの、直結LANで完結する実機Webサーバ侵入ラボです。 + +運営担当は、準備から完全攻略、復旧までをまとめた +[`METHODS.md`](../../METHODS.md)から開始します。参加者にはこの完全解答を見せず、 +標的内の段階型ガイドを使います。 + +## 参加者に見せるゴール + +> 右のノートPCでWebサイトが動いています。右のPCには触らず、Kaliだけを使ってトップページの「本日のお知らせ」を自分たちのチーム名へ書き換えてください。 + +必須ゴールはWebサイトの書き換えです。非公開メモの取得と管理者権限の取得はボーナスとして分離します。 + +## ディレクトリ + +- `target/site`: 演習対象のWebサイト +- `guide`: 参加者向けの段階型ローカルガイド +- `live`: 再起動で初期化されるDebian Live ISOとディスク安全ゲート +- `operator`: 会社での準備、USB検査、当日確認 +- `tests`: ブラウザ内判定と構築資材の自動検査 + +各運営文書の役割と読む順番は [`METHODS.md`](../../METHODS.md) が所有します。 + +## 固定ネットワーク + +- Target: `10.13.37.10/24` +- Attacker: ターゲットのDHCPから`10.13.37.100-150` +- Site: `http://10.13.37.10/` +- Guide: `http://10.13.37.10/start/` + +このアドレスはインターネットや社内LANでは使わず、2台を直結した演習用Ethernet内だけで使用します。 diff --git a/labs/site-takeover/VERIFICATION.md b/labs/site-takeover/VERIFICATION.md new file mode 100644 index 0000000..5bc5bf6 --- /dev/null +++ b/labs/site-takeover/VERIFICATION.md @@ -0,0 +1,108 @@ +# Verification: Site Takeover Live USB + +検証日: 2026-07-27 + +この文書は、GitHubへ公開する前のローカル検証記録です。物理USB、実機ノート、 +借用Kali、GitHub Actionsから生成した最終ISOは、別ゲートとして扱います。 +各節は記載されたtag、commit、SHA-256にだけ適用します。後続releaseはこのRC1の +実測を引き継がず、そのrelease notesとchecksumを別に確認します。 + +## Local automated checks + +- `npm test`: 22/22 pass +- Bash syntax: pass +- PHP lint: 4/4 pass +- PowerShell parse and safe bootstrap self-test: pass +- GitHub Actions YAML parse: pass +- Codex process-group termination with a spawned child: pass +- Firefox participant flow: pass + - Advanced all four required stages through the rendered controls + - Built a team-specific payload + - Submitted that payload through the vulnerable Web form + - Verified the changed homepage through the guide + - Completed the three-question causal debrief +- Firefox layout smoke: target, guide, and staff tool pass at 360x800, + 1280x720, and 1366x768 without horizontal overflow +- Visible form-control names: pass + +## Local ISO + +- File: `site-takeover-live-amd64.iso` +- Size: `1218756608` bytes +- SHA-256: + `0b27a41d72c2b2b03754d74f24c766eed3ad38a003444bf168bd7a3c80c60255` +- External `.sha256` match: pass +- El Torito BIOS entry: present +- El Torito UEFI entry: present + +このISOは未コミット状態のローカルソースから作った機能検証用です。GitHub Actionsの +最終成果物は、Releaseへ添付されたISOとSHA-256を改めて照合します。 + +## Virtual-machine proof + +Debian Liveの標的VMはVirtualBox内部ネットワークだけを持ち、別の攻撃側VMから +その内部ネットワーク経由で確認しました。攻撃側のビルドVMにはhypervisor上の +NATアダプターも残っていたため、この実測は標的側の封じ込めと攻撃経路の証拠であり、 +最終Kali側の隔離合格には使いません。 + +- ディスクなし起動: pre-review ISOで19 preflight checks pass +- Live media: `/run/live/medium` source and filesystem are both `tmpfs` +- Exercise network: no Wi-Fi, DNS, or default route +- Services: HTTP and DHCP active only after the safety gate +- Scan from attacker: TCP 80 open; TCP 22 and 443 closed +- Command execution: `whoami` returns `www-data` +- Required goal: homepage notice changed to `SECURITY TEST SUCCESS: FINAL VM` +- Optional manager note: readable through the intended vulnerable boundary +- Optional root proof: readable through the intended bonus boundary +- Maintenance mode: HTTP/DHCP stopped; Codex CLI `0.145.0` available with + temporary `/run` credentials and no logged-in account +- Return to exercise: preflight passes and HTTP returns +- Reboot: changed notice returns to its original text +- Disk present at boot: `DISK DETECTED - STOP`; HTTP unavailable +- Disk hot-plugged during exercise: guard stops HTTP and blocks the lab +- Disk removed again: guard can return to `EXERCISE READY` + +## Visual evidence + +- `qa/screenshots/site-desktop.png` +- `qa/screenshots/site-360.png` +- `qa/screenshots/guide-desktop.png` +- `qa/screenshots/guide-360.png` +- `qa/screenshots/guide-success.png` +- `qa/screenshots/firefox-guide-success-final.png` +- `qa/screenshots/firefox-site-360-final.png` +- `qa/screenshots/target-ready-final.png` +- `qa/screenshots/target-disk-blocked-final.png` +- `qa/screenshots/target-disk-hotplug-block-final.png` +- `qa/screenshots/target-maintenance-codex-final.png` +- `qa/screenshots/target-exercise-return-final.png` + +## Draft prerelease proof + +- Tag: `site-takeover-live-v0.1.0-rc1` +- Target commit: `b7b9acaa7df430aa92a7447e4ba244d6bc9a6d11` +- State: draft and prerelease; not published +- Exact ISO SHA-256: + `f6d5abfe122aa9f32a19001390ff9d312b26417aa3ae2f18fe2b352e68fc337f` +- Exact ISO size: `1218756608` bytes +- GitHub assets: ISO, external SHA-256, BIOS/UEFI boot report, company setup, + and the hierarchy-preserving operator kit with its own SHA-256 uploaded +- GitHub re-download: SHA-256 match +- Embedded source metadata: target commit and `dirty=false` +- Exact-ISO VM: 22 preflight checks pass, 0 fail +- Exact-ISO attack flow and reboot reset: pass + +GitHub-hosted Actions stopped before its first step because the account reported a +payment/spending-limit gate. The draft asset was therefore built from the exact +commit on the verified Debian 13 VM. An Actions-produced artifact remains a +separate external gate. + +## Gates still requiring physical or external evidence + +- The BUFFALO USB is connected and passes an all-available-space H2testw + write-and-verify run with zero errors. +- The GitHub Actions workflow passes from the committed branch. +- The target laptop boots the USB, reports RAM media, sees no internal SSD, + allows USB removal, and reaches `EXERCISE READY`. +- Both the borrowed Kali laptop and the fallback Kali environment pass + `operator/KALI-PREFLIGHT.md`. diff --git a/labs/site-takeover/guide/guide-model.mjs b/labs/site-takeover/guide/guide-model.mjs new file mode 100644 index 0000000..66f9331 --- /dev/null +++ b/labs/site-takeover/guide/guide-model.mjs @@ -0,0 +1,203 @@ +export const targetIp = "10.13.37.10"; + +export function classifyScanOutput(output) { + const text = String(output ?? ""); + + if (!text.trim()) { + return { + ok: false, + message: "結果がまだ貼り付けられていません。", + }; + } + + if (/80\/tcp\s+open\s+(?:http|apache)/i.test(text)) { + return { + ok: true, + message: "80番の入口でWebサイトが待っていると分かりました。ブラウザで見えるページと同じ相手です。", + }; + } + + if (/host seems down|0 hosts up|failed to resolve/i.test(text)) { + return { + ok: false, + message: "相手PCまで通信が届いていません。LANケーブルとKaliの有線接続表示を運営者と確認してください。", + }; + } + + if (/80\/tcp\s+closed|80\/tcp\s+filtered/i.test(text)) { + return { + ok: false, + message: "80番がopenになっていません。ターゲットの演習モードが起動しているか、運営者と確認してください。", + }; + } + + return { + ok: false, + message: "貼り付けた結果から80/tcpのopen行を認識できませんでした。その行を含めてもう一度貼り付けてください。", + }; +} + +export function classifyDiscoveredPath(value) { + const normalized = String(value ?? "").trim().toLowerCase(); + + if (normalized === "/staff" || normalized === "/staff/") { + return { + ok: true, + message: "スタッフ用の場所を発見しました。robots.txtは秘密の鍵ではなく、誰でも読める手掛かりだった点が重要です。", + }; + } + + return { + ok: false, + message: "Disallow: の右側をそのまま確認してください。staffという文字が含まれます。", + }; +} + +export function classifyCommandOutput(output, prediction) { + const text = String(output ?? ""); + + if (prediction !== "identity") { + return { + ok: false, + message: "whoamiは「この命令を誰として実行しているか」を表示する命令です。目的を選び直してください。", + }; + } + + if (/(?:^|\s)www-data(?:\s|$)|uid=33\(www-data\)/im.test(text)) { + return { + ok: true, + message: "成功です。ブラウザへ入れた文字から、相手PCがwww-dataという利用者で別の命令を実行しました。", + }; + } + + if (!text.trim()) { + return { + ok: false, + message: "確認結果がまだ貼り付けられていません。", + }; + } + + return { + ok: false, + message: "www-dataという行が出ているか確認してください。見当たらない場合はセミコロンが半角かを見直します。", + }; +} + +export function classifyWorkdirOutput(output) { + const text = String(output ?? "").trim(); + + if (/\/srv\/shop-site\/public\/staff\/?$/m.test(text)) { + return { + ok: true, + message: "現在地はshop-siteのpublic/staffです。publicはブラウザへ公開する部分、その外側にもデータがありそうです。", + }; + } + + return { + ok: false, + message: "結果の中に、/srvから始まる現在地があるか確認してください。", + }; +} + +export function classifySiteTreeOutput(output) { + const text = String(output ?? ""); + const hasData = /(?:^|\s)data(?:\s|$)/m.test(text); + const hasPublic = /(?:^|\s)public(?:\s|$)/m.test(text); + + if (hasData && hasPublic) { + return { + ok: true, + message: "公開ページのpublicとは別にdataがあります。トップページが読むお知らせはdata側に置かれていそうです。", + }; + } + + return { + ok: false, + message: "一覧の中にdataとpublicの両方があるか確認してください。", + }; +} + +export function classifyNoticeOutput(output) { + const text = String(output ?? "").trim(); + + if (text.includes("本日は通常どおり営業しています。") || text.includes("SECURITY TEST SUCCESS:")) { + return { + ok: true, + message: "トップページで見た「本日のお知らせ」と同じ内容です。画面の文字が、このファイルから作られているとつながりました。", + }; + } + + return { + ok: false, + message: "トップページの「本日のお知らせ」と同じ文が表示されたか確認してください。", + }; +} + +export function normalizeTeamName(value) { + const normalized = String(value ?? "").normalize("NFKC").trim().replace(/\s+/g, " "); + + if (!normalized) { + return { ok: false, message: "チーム名を入力してください。" }; + } + + if (normalized.length > 24) { + return { ok: false, message: "チーム名は24文字以内にしてください。" }; + } + + if (!/^[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}A-Za-z0-9 _ー-]+$/u.test(normalized)) { + return { + ok: false, + message: "日本語、英数字、空白、ハイフン、アンダースコアだけを使えます。", + }; + } + + return { ok: true, value: normalized }; +} + +export function shellSingleQuote(value) { + return `'${String(value).replaceAll("'", `'\"'\"'`)}'`; +} + +export function buildDefacePayload(teamName) { + const normalized = normalizeTeamName(teamName); + + if (!normalized.ok) { + return normalized; + } + + const message = `SECURITY TEST SUCCESS: ${normalized.value}`; + const payload = `127.0.0.1; printf '%s\\n' ${shellSingleQuote(message)} > ../../data/announcement.txt`; + + return { + ok: true, + team: normalized.value, + message, + payload, + }; +} + +export function homepageShowsTeam(html, teamName) { + const normalized = normalizeTeamName(teamName); + + if (!normalized.ok) { + return false; + } + + return String(html ?? "").includes(`SECURITY TEST SUCCESS: ${normalized.value}`); +} + +export function scoreDebrief(values) { + const correct = { + where: "target", + privilege: "limited", + reason: "file", + }; + const missing = Object.keys(correct).filter((key) => !values[key]); + const wrong = Object.keys(correct).filter((key) => values[key] && values[key] !== correct[key]); + + return { + ok: missing.length === 0 && wrong.length === 0, + missing, + wrong, + }; +} diff --git a/labs/site-takeover/guide/guide.css b/labs/site-takeover/guide/guide.css new file mode 100644 index 0000000..6fd9ffa --- /dev/null +++ b/labs/site-takeover/guide/guide.css @@ -0,0 +1,669 @@ +:root { + color-scheme: light; + --paper: #f7f8f6; + --panel: #ffffff; + --ink: #151b1d; + --muted: #586367; + --accent: #006b67; + --accent-soft: #d9eeeb; + --line: #cfd8d7; + --error: #9b2c2c; + --success: #146b3a; + --mono: "Cascadia Mono", "SFMono-Regular", Consolas, monospace; + --sans: "Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: var(--sans); + line-height: 1.75; +} + +button, +input, +textarea { + font: inherit; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +textarea:focus-visible, +summary:focus-visible { + outline: 3px solid rgba(0, 107, 103, 0.35); + outline-offset: 3px; +} + +.guide-header { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 4rem; + padding: 0 2rem; + border-bottom: 1px solid var(--line); + background: rgba(247, 248, 246, 0.95); + backdrop-filter: blur(12px); +} + +.guide-brand { + display: flex; + align-items: center; + gap: 0.65rem; + color: var(--ink); + font: 700 0.76rem/1 var(--mono); + letter-spacing: 0.12em; + text-decoration: none; +} + +.guide-brand > span:first-child { + color: var(--accent); + font-size: 1.7rem; +} + +#progress-label { + margin: 0; + color: var(--muted); + font: 0.74rem/1 var(--mono); +} + +main { + width: min(1120px, calc(100% - 2.5rem)); + margin: 0 auto; +} + +.mission { + min-height: calc(100vh - 4rem); + padding: clamp(4rem, 9vw, 8rem) 0; +} + +.kicker { + margin: 0 0 1rem; + color: var(--accent); + font: 700 0.74rem/1.4 var(--mono); + letter-spacing: 0.16em; +} + +h1, +h2, +h3, +p { + text-wrap: pretty; +} + +h1, +h2 { + margin: 0; + line-height: 1.35; + letter-spacing: -0.025em; +} + +h1 { + max-width: 16em; + font-size: clamp(2.6rem, 7vw, 5.8rem); +} + +h2 { + max-width: 20em; + font-size: clamp(2rem, 4.5vw, 3.8rem); +} + +h3 { + margin: 0 0 0.75rem; + font-size: 1.15rem; +} + +.mission-copy { + max-width: 42rem; + margin: 2rem 0 3rem; + color: var(--muted); + font-size: 1.08rem; +} + +.mission-grid { + display: grid; + margin: 0 0 3rem; + border-top: 1px solid var(--ink); + border-bottom: 1px solid var(--ink); + grid-template-columns: repeat(3, 1fr); +} + +.mission-grid > div { + min-height: 9rem; + padding: 1.35rem; +} + +.mission-grid > div + div { + border-left: 1px solid var(--line); +} + +.mission-grid .number { + display: block; + margin-bottom: 1.5rem; + color: var(--accent); + font: 0.7rem/1 var(--mono); +} + +.mission-grid strong, +.mission-grid p { + display: block; +} + +.mission-grid p { + margin: 0.35rem 0 0; + color: var(--muted); + font-size: 0.87rem; +} + +code { + font-family: var(--mono); +} + +.primary-link, +.action-link { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + min-height: 3.2rem; + padding: 0 1.2rem; + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + font-weight: 700; + text-decoration: none; +} + +.primary-link { + min-width: 16rem; +} + +.no-script { + margin-bottom: 2rem; + padding: 1rem; + border: 1px solid var(--error); + background: #fff4f4; +} + +.stage { + display: grid; + margin-bottom: 2rem; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.97); + grid-template-columns: 150px minmax(0, 1fr); +} + +.stage.locked .stage-body { + opacity: 0.48; + pointer-events: none; + user-select: none; +} + +.stage.locked { + grid-template-columns: 1fr; +} + +.stage.locked .stage-rail { + flex-direction: row; + border-right: 0; +} + +.stage.complete { + border-color: rgba(20, 107, 58, 0.55); +} + +.stage-rail { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 2rem 1.25rem; + border-right: 1px solid var(--line); + font: 700 0.72rem/1.4 var(--mono); + letter-spacing: 0.1em; +} + +.stage-status { + color: var(--muted); + font-weight: 400; + letter-spacing: 0; +} + +.complete .stage-status { + color: var(--success); +} + +.stage-body { + min-width: 0; + padding: clamp(2rem, 6vw, 4.5rem); +} + +.stage-body > p:not(.kicker) { + max-width: 48rem; + color: var(--muted); +} + +.choice-grid { + display: grid; + gap: 1rem; + margin: 2.5rem 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.choice-grid.compact { + margin-bottom: 1rem; +} + +.choice-button { + position: relative; + display: block; + min-height: 10rem; + padding: 1.4rem; + border: 1px solid var(--line); + background: var(--panel); + color: var(--ink); + cursor: pointer; + text-align: left; +} + +.choice-button:hover { + border-color: var(--accent); + transform: translateY(-2px); +} + +.choice-index { + position: absolute; + top: 1rem; + right: 1rem; + color: var(--accent); + font: 0.76rem/1 var(--mono); +} + +.choice-button strong, +.choice-button small { + display: block; +} + +.choice-button strong { + margin-top: 2rem; + font-size: 1.05rem; +} + +.choice-button small { + margin-top: 0.6rem; + color: var(--muted); + line-height: 1.6; +} + +.reveal-panel, +.payload-panel, +.final-check, +.experiment-intro { + margin: 1rem 0; + padding: 1.5rem; + border-left: 4px solid var(--accent); + background: #f1f6f5; +} + +.reveal-panel[hidden], +.payload-panel[hidden] { + display: none; +} + +label, +legend { + font-weight: 700; +} + +input[type="text"], +textarea { + display: block; + width: 100%; + margin: 0.65rem 0 1rem; + border: 1px solid var(--line); + border-radius: 2px; + background: #fff; + color: var(--ink); +} + +input[type="text"] { + min-height: 3.2rem; + padding: 0 0.9rem; +} + +textarea { + min-height: 9rem; + padding: 0.9rem; + font-family: var(--mono); + line-height: 1.55; + resize: vertical; +} + +.command-block { + overflow-x: auto; + margin: 1rem 0; + padding: 1rem; + border-left: 4px solid var(--accent); + background: var(--ink); + color: #eaf7f5; + font-size: 0.9rem; + white-space: nowrap; +} + +.command-block.large { + white-space: normal; + word-break: break-word; +} + +.command-location { + display: block; + margin-bottom: 0.55rem; + color: #b8dfda; + font: 700 0.72rem/1.3 var(--sans); + letter-spacing: 0.04em; + white-space: normal; +} + +.inline-location { + display: block; + margin-bottom: 0.4rem; + color: var(--accent); + font-size: 0.82rem; + font-weight: 700; +} + +.check-button, +.copy-button { + min-height: 3rem; + padding: 0 1rem; + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + cursor: pointer; + font-weight: 700; +} + +.copy-button { + background: transparent; + color: var(--accent); +} + +.feedback { + min-height: 1.75rem; + margin: 0.75rem 0 0; + font-size: 0.9rem; +} + +.feedback.success { + color: var(--success); + font-weight: 700; +} + +.feedback.error { + color: var(--error); +} + +.quick-question { + display: grid; + gap: 0.7rem; + margin: 1.5rem 0; + padding: 1.25rem; + border: 1px solid var(--line); +} + +.quick-question label, +#debrief-quiz label { + font-weight: 400; +} + +.quick-question input, +#debrief-quiz input { + accent-color: var(--accent); +} + +.hint, +.bonus { + margin-top: 0.75rem; + border-top: 1px solid var(--line); +} + +.hint summary, +.bonus summary { + padding: 1rem 0; + color: var(--accent); + cursor: pointer; + font-weight: 700; +} + +.experiment-list { + margin: 2rem 0; + padding: 0; + list-style: none; + counter-reset: experiment; +} + +.experiment-list li { + display: grid; + padding: 1.5rem 0; + border-top: 1px solid var(--line); + counter-increment: experiment; + grid-template-columns: 3rem 1fr; +} + +.experiment-list li::before { + color: var(--accent); + content: counter(experiment, decimal-leading-zero); + font: 0.8rem/1.75 var(--mono); +} + +.experiment-list p { + margin: 0.25rem 0; + color: var(--muted); +} + +.team-row { + display: grid; + align-items: end; + gap: 0.75rem; + grid-template-columns: 1fr auto; +} + +.team-row input { + margin-bottom: 0; +} + +.field-note { + margin-top: 0.5rem; + font-size: 0.8rem; +} + +.final-check { + margin-top: 2rem; +} + +.final-check .action-link { + margin: 0.5rem 0.5rem 0.5rem 0; +} + +.debrief { + margin: 5rem 0; + padding: clamp(2rem, 6vw, 4.5rem); + border: 2px solid var(--success); + background: #f6fff8; +} + +.debrief.locked { + display: none; +} + +.cause-flow { + display: grid; + margin: 3rem 0; + grid-template-columns: repeat(4, 1fr); +} + +.cause-flow > div { + position: relative; + min-height: 10rem; + padding: 1.2rem; + border: 1px solid var(--line); + background: #fff; +} + +.cause-flow > div + div { + border-left: 0; +} + +.cause-flow > div:not(:last-child)::after { + position: absolute; + z-index: 2; + top: 50%; + right: -0.65rem; + display: grid; + width: 1.3rem; + height: 1.3rem; + place-items: center; + border-radius: 50%; + background: var(--accent); + color: #fff; + content: "›"; +} + +.cause-flow span { + display: block; + margin-bottom: 2rem; + color: var(--accent); + font: 0.72rem/1 var(--mono); +} + +.cause-flow strong { + display: block; +} + +.cause-flow p { + margin: 0.45rem 0 0; + color: var(--muted); + font-size: 0.82rem; +} + +#debrief-quiz { + display: grid; + gap: 1rem; +} + +#debrief-quiz fieldset { + display: grid; + gap: 0.65rem; + padding: 1.2rem; + border: 1px solid var(--line); + background: #fff; +} + +.bonus { + margin-top: 2rem; +} + +.guide-footer { + display: flex; + justify-content: space-between; + gap: 2rem; + width: min(1120px, calc(100% - 2.5rem)); + margin: 0 auto; + padding: 2rem 0 4rem; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 0.8rem; +} + +.guide-footer button { + padding: 0; + border: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 0.25rem; +} + +@media (max-width: 760px) { + .guide-header { + padding: 0 1rem; + } + + .guide-brand span:last-child { + display: none; + } + + main { + width: min(100% - 1rem, 1120px); + } + + .mission-grid, + .choice-grid, + .team-row, + .cause-flow { + grid-template-columns: 1fr; + } + + .mission-grid > div + div { + border-top: 1px solid var(--line); + border-left: 0; + } + + .stage { + grid-template-columns: 1fr; + } + + .stage-rail { + flex-direction: row; + padding: 1rem; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .stage-body { + padding: 1.5rem 1rem 2.25rem; + } + + .cause-flow > div + div { + border-top: 0; + border-left: 1px solid var(--line); + } + + .cause-flow > div:not(:last-child)::after { + top: auto; + right: 50%; + bottom: -0.65rem; + transform: translateX(50%) rotate(90deg); + } + + .guide-footer { + align-items: flex-start; + flex-direction: column; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + .choice-button { + transition: none; + } +} diff --git a/labs/site-takeover/guide/guide.mjs b/labs/site-takeover/guide/guide.mjs new file mode 100644 index 0000000..4fe9113 --- /dev/null +++ b/labs/site-takeover/guide/guide.mjs @@ -0,0 +1,245 @@ +import { + buildDefacePayload, + classifyCommandOutput, + classifyDiscoveredPath, + classifyNoticeOutput, + classifyScanOutput, + classifySiteTreeOutput, + classifyWorkdirOutput, + homepageShowsTeam, + scoreDebrief, +} from "/start/guide-model.mjs"; + +const labInstanceId = "__LAB_INSTANCE_ID__"; +const storageKey = `site-takeover-lab-progress-v2-${labInstanceId}`; +const defaultState = { + completed: [], + team: "", +}; + +let state = loadState(); + +function loadState() { + try { + return { ...defaultState, ...JSON.parse(localStorage.getItem(storageKey) ?? "{}") }; + } catch { + return { ...defaultState }; + } +} + +function saveState() { + localStorage.setItem(storageKey, JSON.stringify(state)); +} + +function feedback(element, message, ok = false) { + element.textContent = message; + element.classList.toggle("success", ok); + element.classList.toggle("error", !ok); +} + +function completeStage(stageNumber) { + if (!state.completed.includes(stageNumber)) { + state.completed.push(stageNumber); + state.completed.sort(); + saveState(); + } + renderProgress(); +} + +function renderProgress() { + const completeCount = state.completed.filter((stage) => stage >= 1 && stage <= 4).length; + const progressLabel = document.querySelector("#progress-label"); + progressLabel.textContent = completeCount === 4 ? "必須ゴール達成" : `必須ゴール ${completeCount} / 4`; + + for (let stage = 1; stage <= 4; stage += 1) { + const section = document.querySelector(`#stage-${stage}`); + const status = document.querySelector(`#stage-${stage}-status`); + const previousComplete = stage === 1 || state.completed.includes(stage - 1); + const complete = state.completed.includes(stage); + const body = section.querySelector(".stage-body"); + + section.classList.toggle("locked", !previousComplete && !complete); + section.classList.toggle("complete", complete); + body.hidden = !previousComplete && !complete; + body.inert = !previousComplete && !complete; + status.textContent = complete ? "完了" : previousComplete ? "進行中" : `Stage ${stage - 1}の後`; + } + + const debrief = document.querySelector("#debrief"); + debrief.classList.toggle("locked", !state.completed.includes(4)); +} + +document.querySelectorAll("[data-reveal]").forEach((button) => { + button.addEventListener("click", () => { + const panel = document.querySelector(`#${button.dataset.reveal}`); + panel.hidden = false; + panel.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }); +}); + +document.querySelector("#check-site-kind").addEventListener("click", () => { + const choice = document.querySelector('input[name="site-kind"]:checked')?.value; + const output = document.querySelector("#site-kind-feedback"); + + if (choice === "cafe") { + feedback(output, "確認できました。これは別のPC上で動く店のWebサイトです。次は、画面に出ていない場所を探します。", true); + completeStage(1); + return; + } + + feedback(output, choice ? "もう一度、新しいタブに見えた画面を確認してください。" : "見えたものを一つ選んでください。"); +}); + +document.querySelector("#check-scan").addEventListener("click", () => { + const result = classifyScanOutput(document.querySelector("#scan-output").value); + feedback(document.querySelector("#scan-feedback"), result.message, result.ok); + if (result.ok) completeStage(1); +}); + +document.querySelector("#check-path").addEventListener("click", () => { + const result = classifyDiscoveredPath(document.querySelector("#discovered-path").value); + feedback(document.querySelector("#path-feedback"), result.message, result.ok); + if (result.ok) completeStage(2); +}); + +document.querySelector("#reveal-whoami").addEventListener("click", () => { + const prediction = document.querySelector('input[name="prediction"]:checked')?.value; + const output = document.querySelector("#prediction-feedback"); + const panel = document.querySelector("#whoami-experiment"); + + if (prediction === "identity") { + feedback(output, "目的が決まりました。Webの入力が、相手PC上で誰として動くかを確かめます。", true); + panel.hidden = false; + panel.scrollIntoView({ behavior: "smooth", block: "nearest" }); + return; + } + + panel.hidden = true; + feedback(output, prediction ? "この命令で分かるものをもう一度考えてみてください。" : "知りたいことを一つ選んでください。"); +}); + +document.querySelector("#check-command").addEventListener("click", () => { + const prediction = document.querySelector('input[name="prediction"]:checked')?.value; + const result = classifyCommandOutput(document.querySelector("#command-output").value, prediction); + feedback(document.querySelector("#command-feedback"), result.message, result.ok); + if (result.ok) completeStage(3); +}); + +document.querySelector("#check-workdir").addEventListener("click", () => { + const result = classifyWorkdirOutput(document.querySelector("#workdir-output").value); + feedback(document.querySelector("#workdir-feedback"), result.message, result.ok); + document.querySelector("#tree-experiment").hidden = !result.ok; +}); + +document.querySelector("#check-tree").addEventListener("click", () => { + const result = classifySiteTreeOutput(document.querySelector("#tree-output").value); + feedback(document.querySelector("#tree-feedback"), result.message, result.ok); + document.querySelector("#notice-experiment").hidden = !result.ok; +}); + +document.querySelector("#check-notice").addEventListener("click", () => { + const result = classifyNoticeOutput(document.querySelector("#notice-output").value); + feedback(document.querySelector("#notice-feedback"), result.message, result.ok); + document.querySelector("#deface-builder").hidden = !result.ok; +}); + +document.querySelector("#build-payload").addEventListener("click", () => { + const result = buildDefacePayload(document.querySelector("#team-name").value); + const panel = document.querySelector("#payload-panel"); + const output = document.querySelector("#copy-feedback"); + + if (!result.ok) { + panel.hidden = false; + document.querySelector("#deface-payload").textContent = ""; + feedback(output, result.message); + return; + } + + state.team = result.team; + saveState(); + document.querySelector("#deface-payload").textContent = result.payload; + panel.hidden = false; + feedback(output, "自分用の入力を作りました。何を書き込む命令なのか、実行前に文字列を確認してください。", true); + panel.scrollIntoView({ behavior: "smooth", block: "nearest" }); +}); + +document.querySelector("#copy-payload").addEventListener("click", async () => { + const payload = document.querySelector("#deface-payload").textContent; + const output = document.querySelector("#copy-feedback"); + + if (!payload) { + feedback(output, "先にチーム名を入れて、自分用の入力を作ってください。"); + return; + } + + try { + await navigator.clipboard.writeText(payload); + feedback(output, "コピーしました。接続先の入力欄へ貼り付けます。", true); + } catch { + const range = document.createRange(); + range.selectNodeContents(document.querySelector("#deface-payload")); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + feedback(output, "文字列を選択しました。Ctrl+Cでコピーしてください。", true); + } +}); + +document.querySelector("#check-deface").addEventListener("click", async () => { + const output = document.querySelector("#deface-feedback"); + const team = state.team || document.querySelector("#team-name").value; + + if (!team) { + feedback(output, "確認するチーム名がありません。先に自分用の入力を作ってください。"); + return; + } + + try { + const response = await fetch(`/?lab-check=${Date.now()}`, { cache: "no-store" }); + const html = await response.text(); + + if (response.ok && homepageShowsTeam(html, team)) { + feedback(output, `侵入成功です。相手PCのWebサイトに「${team}」が表示されています。`, true); + completeStage(4); + document.querySelector("#debrief").scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + + feedback(output, "まだトップページからチーム名を確認できません。入力を実行したか、別タブを再読み込みして確認してください。"); + } catch { + feedback(output, "ターゲットサイトへ接続できません。LANケーブルと演習モードを運営者と確認してください。"); + } +}); + +document.querySelector("#debrief-quiz").addEventListener("submit", (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const values = Object.fromEntries(form.entries()); + const result = scoreDebrief(values); + const output = document.querySelector("#quiz-feedback"); + + if (result.ok) { + feedback(output, "説明できています。ブラウザの入力が相手PCの命令になり、限定利用者がページの元データを書き換えました。", true); + return; + } + + if (result.missing.length > 0) { + feedback(output, "まだ選んでいない問いがあります。4枚の因果カードと対応させてみてください。"); + return; + } + + feedback(output, "4枚の因果カードを左から読み、命令が動いた場所と利用者をもう一度つないでみてください。"); +}); + +document.querySelector("#reset-progress").addEventListener("click", () => { + const confirmed = window.confirm("このブラウザに保存した演習進捗とチーム名を消しますか?"); + if (!confirmed) return; + localStorage.removeItem(storageKey); + window.location.reload(); +}); + +if (state.team) { + document.querySelector("#team-name").value = state.team; +} + +renderProgress(); diff --git a/labs/site-takeover/guide/index.html b/labs/site-takeover/guide/index.html new file mode 100644 index 0000000..b25dd31 --- /dev/null +++ b/labs/site-takeover/guide/index.html @@ -0,0 +1,369 @@ + + + + + + + Webサイト侵入体験 | ガイド + + + +
+ + + WEB SITE TAKEOVER LAB + +

準備中

+
+ +
+
+

YOUR MISSION

+

目の前のWebサイトを、
自分たちの名前に書き換える。

+

+ 右のノートPCには触りません。操作するのはKaliだけです。 + 「何を知りたいか」を選びながら、Webサイトの裏側までたどります。 +

+
+
+ 01 + 対象 +

10.13.37.10 のWebサイトだけ

+
+
+ 02 + 成功 +

「本日のお知らせ」にチーム名が出る

+
+
+ 03 + 禁止 +

ほかのIPやインターネットを調べない

+
+
+ ミッションを始める +
+ + + +
+
+ STAGE 1 + 未完了 +
+ +
+ +
+
+ STAGE 2 + Stage 1の後 +
+ +
+ +
+
+ STAGE 3 + Stage 2の後 +
+ +
+ +
+
+ STAGE 4 + Stage 3の後 +
+ +
+ +
+

YOU CHANGED THE SITE

+

侵入成功。何が起きたかをつなげよう。

+
+
1ブラウザの入力

接続先の文字を送った

+
2Webのプログラム

文字を安全に分けずOSへ渡した

+
3相手PCの命令

www-dataとして実行された

+
4ページの元データ

お知らせが上書きされた

+
+ +
+
+ 命令が実際に動いた場所は? + + +
+
+ 最初からPC全体の管理者だった? + + +
+
+ 再読み込みで表示が変わった理由は? + + +
+ + +
+ +
+ まだ時間がある: ボーナスへ進む +
+

ボーナス1: 公開されていない店長メモ

+

いま使える命令で、公開フォルダの外側にある../../private/manager-note.txtを読めるか試します。

+

ボーナス2: PC全体の管理者権限

+

sudo -lで、Web用の利用者に誤って許された管理作業がないかを確認します。続きは運営者から追加カードを受け取ってください。

+
+
+
+
+ + + + + + diff --git a/labs/site-takeover/live/README.md b/labs/site-takeover/live/README.md new file mode 100644 index 0000000..ff7d7ec --- /dev/null +++ b/labs/site-takeover/live/README.md @@ -0,0 +1,39 @@ +# Live ISO build + +Debian 13の一時ビルド環境でだけ実行します。必要なパッケージを導入します。 + +```bash +sudo apt-get update +sudo apt-get install -y --no-install-recommends \ + ca-certificates git live-build rsync wget xorriso +``` + +リポジトリのルートから次を実行します。 + +```bash +sudo ./labs/site-takeover/live/build.sh +``` + +次の4ファイルを生成します。 + +```text +labs/site-takeover/live/dist/site-takeover-live-amd64.iso +labs/site-takeover/live/dist/site-takeover-live-amd64.iso.sha256 +labs/site-takeover/live/dist/site-takeover-live-amd64.boot.txt +labs/site-takeover/live/dist/build.log +``` + +同じディレクトリでSHA-256を照合します。 + +```bash +cd labs/site-takeover/live/dist +sha256sum --check site-takeover-live-amd64.iso.sha256 +grep -E 'BIOS|UEFI' site-takeover-live-amd64.boot.txt +``` + +ビルドには`/var/tmp`配下に10GiB以上の空きが必要です。生成物はGitへ追加しません。 +Ubuntuに入る旧版`live-build`やWindows上の代替手順ではなく、Debian 13の +`live-build`を使います。ISOが生成できても、VMと物理環境の検証が終わるまでは +配布候補にしません。 + +起動時は`toram nopersistence`を必須にします。LiveメディアがRAM上へコピーされたことを検査してからUSB取り外しを案内し、物理ディスクが0台になり、有線LANへリンクした場合だけ演習サービスが起動します。 diff --git a/labs/site-takeover/live/assets/manager-note.txt b/labs/site-takeover/live/assets/manager-note.txt new file mode 100644 index 0000000..ae015aa --- /dev/null +++ b/labs/site-takeover/live/assets/manager-note.txt @@ -0,0 +1,6 @@ +店長メモ(演習用) + +Webサイトから公開する予定のないファイルです。 +これをWeb用の利用者から読めたなら、ページ改ざんだけでなく情報流出も起こせる状態です。 + +BONUS-PROOF: LAB{limited_user_read_private_note} diff --git a/labs/site-takeover/live/assets/root-proof.txt b/labs/site-takeover/live/assets/root-proof.txt new file mode 100644 index 0000000..f870fae --- /dev/null +++ b/labs/site-takeover/live/assets/root-proof.txt @@ -0,0 +1,3 @@ +ROOT BONUS COMPLETE +ROOT-PROOF: LAB{misconfigured_maintenance_reached_root} +このファイルはPC全体を変更できる管理者だけが読めます。 diff --git a/labs/site-takeover/live/build.sh b/labs/site-takeover/live/build.sh new file mode 100755 index 0000000..20deda0 --- /dev/null +++ b/labs/site-takeover/live/build.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly LAB_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +readonly REPO_ROOT="$(cd -- "${LAB_ROOT}/../.." && pwd)" +readonly DIST_DIR="${SCRIPT_DIR}/dist" +readonly OUTPUT_ISO="${DIST_DIR}/site-takeover-live-amd64.iso" +readonly OUTPUT_CHECKSUM="${OUTPUT_ISO}.sha256" +readonly OUTPUT_BOOT_REPORT="${DIST_DIR}/site-takeover-live-amd64.boot.txt" +readonly OUTPUT_BUILD_LOG="${DIST_DIR}/build.log" +readonly WORK_PARENT="${LIVE_BUILD_WORK_PARENT:-/var/tmp}" +readonly MINIMUM_FREE_BYTES=10737418240 + +work_root="" + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +repo_git() { + git -c safe.directory="${REPO_ROOT}" -C "${REPO_ROOT}" "$@" +} + +cleanup() { + if [[ -n "${work_root}" && -d "${work_root}" ]]; then + ( + cd "${work_root}" + lb clean --purge >/dev/null 2>&1 || true + ) + cd / + rm -rf -- "${work_root}" + fi +} + +trap cleanup EXIT + +[[ "${EUID}" -eq 0 ]] || die "Run with sudo on Debian 13." +[[ -r /etc/os-release ]] || die "Cannot identify the build system." + +# shellcheck disable=SC1091 +source /etc/os-release +[[ "${ID:-}" == "debian" && "${VERSION_ID:-}" == 13* ]] || + die "Build on Debian 13; found ${PRETTY_NAME:-unknown}." + +for command_name in git lb rsync sha256sum wget xorriso; do + command -v "${command_name}" >/dev/null 2>&1 || + die "Missing build command: ${command_name}" +done + +[[ "${WORK_PARENT}" == /* && -d "${WORK_PARENT}" && -w "${WORK_PARENT}" ]] || + die "LIVE_BUILD_WORK_PARENT must be an existing writable absolute directory." + +available_bytes="$(df -B1 --output=avail "${WORK_PARENT}" | tail -n 1 | tr -d '[:space:]')" +[[ "${available_bytes}" =~ ^[0-9]+$ ]] || + die "Could not determine free space under ${WORK_PARENT}." +((available_bytes >= MINIMUM_FREE_BYTES)) || + die "Live build needs at least 10 GiB free under ${WORK_PARENT}; found ${available_bytes} bytes." + +for required_path in \ + "${LAB_ROOT}/target/site/index.php" \ + "${LAB_ROOT}/guide/index.html" \ + "${SCRIPT_DIR}/config/package-lists/site-takeover.list.chroot" \ + "${SCRIPT_DIR}/config/includes.chroot/usr/local/sbin/lab-guard"; do + [[ -e "${required_path}" ]] || die "Missing build input: ${required_path}" +done + +work_root="$(mktemp -d "${WORK_PARENT%/}/site-takeover-live-build.XXXXXX")" +install -d -m 0755 "${DIST_DIR}" +rm -f -- \ + "${OUTPUT_ISO}" \ + "${OUTPUT_CHECKSUM}" \ + "${OUTPUT_BOOT_REPORT}" \ + "${OUTPUT_BUILD_LOG}" + +cd "${work_root}" +lb config noauto \ + --clean \ + --ignore-system-defaults \ + --mode debian \ + --system live \ + --distribution trixie \ + --architecture amd64 \ + --binary-image iso-hybrid \ + --bootloaders "syslinux grub-efi" \ + --uefi-secure-boot enable \ + --archive-areas "main non-free-firmware" \ + --debian-installer none \ + --firmware-chroot true \ + --apt-indices false \ + --apt-source-archives false \ + --apt-recommends true \ + --debootstrap-options "--variant=minbase" \ + --source false \ + --cache false \ + --memtest none \ + --win32-loader false \ + --checksums sha256 \ + --chroot-squashfs-compression-type xz \ + --utc-time true \ + --image-name site-takeover-live \ + --iso-application "Site Takeover Lab" \ + --iso-publisher "skjshr/tgtsec" \ + --iso-volume "SITE_TAKEOVER_LIVE" \ + --bootappend-live "boot=live components toram nopersistence noeject ip=frommedia overlay-size=1024m username=lab hostname=site-target locales=ja_JP.UTF-8 keyboard-layouts=jp timezone=Asia/Tokyo" \ + --bootappend-live-failsafe none + +rsync -a "${SCRIPT_DIR}/config/" "${work_root}/config/" + +readonly source_root="${work_root}/config/includes.chroot/usr/local/share/site-takeover-source" +install -d -m 0755 \ + "${source_root}/public" \ + "${source_root}/guide" \ + "${source_root}/private" +rsync -a --delete "${LAB_ROOT}/target/site/" "${source_root}/public/" +rsync -a --delete "${LAB_ROOT}/guide/" "${source_root}/guide/" +install -m 0644 "${SCRIPT_DIR}/assets/manager-note.txt" "${source_root}/private/manager-note.txt" +install -m 0600 "${SCRIPT_DIR}/assets/root-proof.txt" "${source_root}/root-proof.txt" + +source_commit="$(repo_git rev-parse HEAD)" +source_branch="$(repo_git branch --show-current)" +source_dirty="false" +if [[ -n "$(repo_git status --porcelain)" ]]; then + source_dirty="true" +fi + +cat > "${work_root}/config/includes.chroot/usr/local/share/site-takeover-build.txt" <&1 | tee "${OUTPUT_BUILD_LOG}" + +mapfile -t built_isos < <(find "${work_root}" -maxdepth 1 -type f -name '*.hybrid.iso' -print) +[[ "${#built_isos[@]}" -eq 1 ]] || + die "Expected exactly one hybrid ISO; found ${#built_isos[@]}." +built_iso="${built_isos[0]}" + +iso_size="$(stat -c %s "${built_iso}")" +((iso_size < 1932735283)) || + die "ISO exceeds the 1.8 GiB release gate: ${iso_size} bytes" + +xorriso -indev "${built_iso}" -report_el_torito plain \ + > "${OUTPUT_BOOT_REPORT}" 2>&1 +grep -Fq 'BIOS' "${OUTPUT_BOOT_REPORT}" || + die "ISO does not report a BIOS boot entry." +grep -Fq 'UEFI' "${OUTPUT_BOOT_REPORT}" || + die "ISO does not report a UEFI boot entry." + +install -m 0644 "${built_iso}" "${OUTPUT_ISO}" +( + cd "${DIST_DIR}" + sha256sum "$(basename "${OUTPUT_ISO}")" \ + > "$(basename "${OUTPUT_CHECKSUM}")" +) + +printf 'Built %s (%s bytes)\n' "${OUTPUT_ISO}" "${iso_size}" +cat "${OUTPUT_CHECKSUM}" diff --git a/labs/site-takeover/live/config/hooks/live/010-site-takeover.hook.chroot b/labs/site-takeover/live/config/hooks/live/010-site-takeover.hook.chroot new file mode 100755 index 0000000..a08bf34 --- /dev/null +++ b/labs/site-takeover/live/config/hooks/live/010-site-takeover.hook.chroot @@ -0,0 +1,32 @@ +#!/bin/sh + +set -eu + +chmod 0755 \ + /usr/local/bin/codex-maintenance \ + /usr/local/bin/lab-console \ + /usr/local/sbin/lab-guard \ + /usr/local/sbin/lab-mode \ + /usr/local/sbin/lab-preflight \ + /usr/local/sbin/lab-reset-runtime \ + /usr/local/sbin/site-maintenance +chmod 0644 /usr/local/lib/site-lab/common.sh +chmod 0440 /etc/sudoers.d/site-takeover-lab +visudo -cf /etc/sudoers.d/site-takeover-lab + +a2dissite 000-default.conf >/dev/null +a2ensite site-takeover.conf >/dev/null +a2enmod env >/dev/null +apache2ctl configtest + +systemctl disable apache2.service >/dev/null 2>&1 || true +systemctl disable nftables.service >/dev/null 2>&1 || true +systemctl enable NetworkManager.service >/dev/null 2>&1 || true +systemctl enable site-lab-guard.service >/dev/null 2>&1 || true + +npm install --global @openai/codex@0.145.0 +codex --version > /usr/local/share/site-takeover-codex-version.txt +npm cache clean --force >/dev/null 2>&1 || true +rm -rf /root/.codex /root/.npm + +printf '%s\n' guarded > /etc/site-takeover-default-state diff --git a/labs/site-takeover/live/config/includes.chroot/etc/apache2/sites-available/site-takeover.conf b/labs/site-takeover/live/config/includes.chroot/etc/apache2/sites-available/site-takeover.conf new file mode 100644 index 0000000..52a69ba --- /dev/null +++ b/labs/site-takeover/live/config/includes.chroot/etc/apache2/sites-available/site-takeover.conf @@ -0,0 +1,28 @@ + + ServerName 10.13.37.10 + DocumentRoot /srv/shop-site/public + + SetEnv SITE_NOTICE_PATH /srv/shop-site/data/announcement.txt + + Alias /start/ /srv/shop-site/guide/ + + + Options -Indexes + AllowOverride None + Require all granted + DirectoryIndex index.php + + + + Options -Indexes + AllowOverride None + Require all granted + DirectoryIndex index.html + + + ErrorLog ${APACHE_LOG_DIR}/site-takeover-error.log + CustomLog ${APACHE_LOG_DIR}/site-takeover-access.log combined + + +ServerTokens Prod +ServerSignature Off diff --git a/labs/site-takeover/live/config/includes.chroot/etc/profile.d/site-lab-console.sh b/labs/site-takeover/live/config/includes.chroot/etc/profile.d/site-lab-console.sh new file mode 100644 index 0000000..17d690e --- /dev/null +++ b/labs/site-takeover/live/config/includes.chroot/etc/profile.d/site-lab-console.sh @@ -0,0 +1,3 @@ +if [ -t 1 ] && [ "$(tty 2>/dev/null)" = "/dev/tty1" ]; then + /usr/local/bin/lab-console +fi diff --git a/labs/site-takeover/live/config/includes.chroot/etc/sudoers.d/site-takeover-lab b/labs/site-takeover/live/config/includes.chroot/etc/sudoers.d/site-takeover-lab new file mode 100644 index 0000000..cac08e9 --- /dev/null +++ b/labs/site-takeover/live/config/includes.chroot/etc/sudoers.d/site-takeover-lab @@ -0,0 +1,6 @@ +# Local operator access on the diskless Live system. +lab ALL=(ALL:ALL) NOPASSWD: ALL + +# Deliberately over-broad for the isolated root-bonus chapter. +# This file must never be installed outside the disposable training target. +www-data ALL=(root) NOPASSWD: /usr/local/sbin/site-maintenance * diff --git a/labs/site-takeover/live/config/includes.chroot/etc/systemd/system/site-lab-dhcp.service b/labs/site-takeover/live/config/includes.chroot/etc/systemd/system/site-lab-dhcp.service new file mode 100644 index 0000000..9620ac0 --- /dev/null +++ b/labs/site-takeover/live/config/includes.chroot/etc/systemd/system/site-lab-dhcp.service @@ -0,0 +1,10 @@ +[Unit] +Description=Site Takeover Lab DHCP server +After=NetworkManager.service +Requires=NetworkManager.service + +[Service] +Type=simple +ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --conf-file=/run/site-lab-dnsmasq.conf +Restart=on-failure +RestartSec=2 diff --git a/labs/site-takeover/live/config/includes.chroot/etc/systemd/system/site-lab-guard.service b/labs/site-takeover/live/config/includes.chroot/etc/systemd/system/site-lab-guard.service new file mode 100644 index 0000000..15f95b8 --- /dev/null +++ b/labs/site-takeover/live/config/includes.chroot/etc/systemd/system/site-lab-guard.service @@ -0,0 +1,17 @@ +[Unit] +Description=Guard and arm the diskless Site Takeover Lab +After=live-config.service NetworkManager.service +Wants=NetworkManager.service +Before=apache2.service + +[Service] +Type=simple +ExecStart=/usr/local/sbin/lab-guard +Restart=on-failure +RestartSec=5 +TimeoutStartSec=0 +StandardOutput=journal+console +StandardError=journal+console + +[Install] +WantedBy=multi-user.target diff --git a/labs/site-takeover/live/config/includes.chroot/usr/local/bin/codex-maintenance b/labs/site-takeover/live/config/includes.chroot/usr/local/bin/codex-maintenance new file mode 100644 index 0000000..5ea4aa5 --- /dev/null +++ b/labs/site-takeover/live/config/includes.chroot/usr/local/bin/codex-maintenance @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +# shellcheck disable=SC1091 +source /usr/local/lib/site-lab/common.sh + +if [[ "$(site_lab_state)" != "maintenance" ]]; then + printf 'Codex is allowed only in maintenance mode.\n' >&2 + printf 'Run: sudo lab-mode maintenance\n' >&2 + exit 1 +fi + +command -v codex >/dev/null 2>&1 || { + printf 'Codex CLI is not installed in this image.\n' >&2 + exit 1 +} + +umask 077 +export CODEX_HOME="${SITE_LAB_CODEX_RUNTIME}/$(id -u)" +install -d -m 0700 "${CODEX_HOME}" + +cat > "${CODEX_HOME}/config.toml" <<'EOF' +cli_auth_credentials_store = "file" +approval_policy = "on-request" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +network_access = true +EOF + +if ! codex login status >/dev/null 2>&1; then + cat <<'EOF' +No temporary Codex login is present. +Use: codex-maintenance login --device-auth + +Credentials stay under /run and disappear in exercise mode or after reboot. +EOF +fi + +setsid codex "$@" & +codex_pgid="$!" +printf '%s\n' "${codex_pgid}" > "${CODEX_HOME}/pgid" +trap 'site_lab_terminate_process_group "${codex_pgid}"; rm -f -- "${CODEX_HOME}/pgid"' EXIT INT TERM +wait "${codex_pgid}" diff --git a/labs/site-takeover/live/config/includes.chroot/usr/local/bin/lab-console b/labs/site-takeover/live/config/includes.chroot/usr/local/bin/lab-console new file mode 100644 index 0000000..d89aaf7 --- /dev/null +++ b/labs/site-takeover/live/config/includes.chroot/usr/local/bin/lab-console @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -u + +# shellcheck disable=SC1091 +source /usr/local/lib/site-lab/common.sh + +printf '\nSITE TAKEOVER LAB\n' +printf '=================\n' +printf 'State: %s\n\n' "$(site_lab_state)" + +case "$(site_lab_state)" in + waiting-for-usb) + printf 'Remove the Live USB.\n' + printf 'If a disk remains listed, disable the internal SSD in UEFI.\n\n' + site_lab_physical_disks + ;; + waiting-for-lan) + printf 'No physical disk is visible.\n' + printf 'Connect the direct Ethernet cable to Kali.\n' + ;; + disk-blocked) + printf 'DISK DETECTED - STOP\n' + printf 'Exercise services are OFF.\n' + printf 'Reboot and disable the internal SSD in UEFI.\n\n' + site_lab_physical_disks + ;; + exercise) + printf 'Target: http://10.13.37.10/\n' + printf 'Guide : http://10.13.37.10/start/\n' + printf 'Participants must not touch this keyboard.\n' + ;; + maintenance) + printf 'Lab services are OFF.\n' + printf 'Use nmtui for a personal tether, then codex-maintenance.\n' + ;; + blocked) + printf 'Safety check failed. Exercise services are OFF.\n' + cat /etc/issue 2>/dev/null || true + ;; + *) + printf 'The guard is preparing the diskless target.\n' + ;; +esac +printf '\n' diff --git a/labs/site-takeover/live/config/includes.chroot/usr/local/lib/site-lab/common.sh b/labs/site-takeover/live/config/includes.chroot/usr/local/lib/site-lab/common.sh new file mode 100644 index 0000000..f5778a8 --- /dev/null +++ b/labs/site-takeover/live/config/includes.chroot/usr/local/lib/site-lab/common.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash + +readonly SITE_LAB_STATE_FILE="/run/site-takeover-mode" +readonly SITE_LAB_INTERFACE_FILE="/run/site-lab-interface" +readonly SITE_LAB_CODEX_RUNTIME="/run/site-lab-codex" +readonly SITE_LAB_SOURCE="/usr/local/share/site-takeover-source" + +site_lab_state() { + if [[ -r "${SITE_LAB_STATE_FILE}" ]]; then + cat "${SITE_LAB_STATE_FILE}" + else + printf 'guarded\n' + fi +} + +site_lab_set_state() { + local state="$1" + printf '%s\n' "${state}" > "${SITE_LAB_STATE_FILE}" + chmod 0644 "${SITE_LAB_STATE_FILE}" +} + +site_lab_write_issue() { + local title="$1" + shift + { + printf '\nSITE TAKEOVER LAB\n' + printf 'STATE: %s\n\n' "${title}" + printf '%s\n' "$@" + printf '\nStatus command: lab-console\n\n' + } > /etc/issue +} + +site_lab_physical_disks() { + lsblk -dn -e 1,7,11 -o NAME,TYPE,RO,TRAN,SIZE,MODEL | + awk '$2 == "disk" { print }' +} + +site_lab_usb_disks() { + lsblk -dn -e 1,7,11 -o NAME,TYPE,TRAN | + awk '$2 == "disk" && $3 == "usb" { print $1 }' +} + +site_lab_assert_no_physical_disks() { + local disks + disks="$(site_lab_physical_disks)" + if [[ -n "${disks}" ]]; then + printf 'Physical disk access is blocked. Visible disk(s):\n%s\n' "${disks}" >&2 + return 1 + fi +} + +site_lab_assert_live_ram() { + grep -qw toram /proc/cmdline || + { + printf 'The Live system was not started with toram.\n' >&2 + return 1 + } + grep -qw nopersistence /proc/cmdline || + { + printf 'The Live system was not started with nopersistence.\n' >&2 + return 1 + } + findmnt -n -o FSTYPE / | grep -Eq '^(overlay|overlayfs)$' || + { + printf 'The root filesystem is not a Live overlay.\n' >&2 + return 1 + } +} + +site_lab_assert_live_media_in_ram() { + local source filesystem + + mountpoint -q /run/live/medium || + { + printf 'The Live medium mount is missing.\n' >&2 + return 1 + } + + source="$(findmnt -n -o SOURCE --target /run/live/medium)" + filesystem="$(findmnt -n -o FSTYPE --target /run/live/medium)" + if [[ "${source}" != "tmpfs" || "${filesystem}" != "tmpfs" ]]; then + printf 'The Live medium is not RAM-backed (source=%s, fstype=%s).\n' \ + "${source:-unknown}" "${filesystem:-unknown}" >&2 + return 1 + fi +} + +site_lab_find_ethernet_with_carrier() { + local path name + for path in /sys/class/net/*; do + name="${path##*/}" + [[ "${name}" != "lo" ]] || continue + [[ -e "${path}/device" ]] || continue + [[ ! -d "${path}/wireless" ]] || continue + [[ "$(<"${path}/type")" == "1" ]] || continue + [[ -r "${path}/carrier" && "$(<"${path}/carrier")" == "1" ]] || continue + printf '%s\n' "${name}" + return 0 + done + return 1 +} + +site_lab_lock_network() { + local device state + + systemctl stop site-lab-dhcp.service apache2.service >/dev/null 2>&1 || true + nmcli radio wifi off >/dev/null 2>&1 || true + + while IFS=: read -r device state; do + if [[ -n "${device}" && "${device}" != "lo" && "${state}" == "connected" ]]; then + nmcli device disconnect "${device}" >/dev/null 2>&1 || true + fi + done < <(LC_ALL=C nmcli -t -f DEVICE,STATE device status) + + cat > /run/site-lab-guard.nft <<'EOF' +flush ruleset +table inet site_guard { + chain input { + type filter hook input priority 0; policy drop; + iifname "lo" accept + } + chain forward { + type filter hook forward priority 0; policy drop; + } + chain output { + type filter hook output priority 0; policy drop; + oifname "lo" accept + } +} +EOF + nft -f /run/site-lab-guard.nft +} + +site_lab_terminate_process_group() { + local pgid="$1" + + [[ "${pgid}" =~ ^[0-9]+$ && "${pgid}" -gt 1 ]] || return 0 + kill -TERM -- "-${pgid}" >/dev/null 2>&1 || return 0 + + for _ in {1..20}; do + kill -0 -- "-${pgid}" >/dev/null 2>&1 || return 0 + sleep 0.1 + done + + kill -KILL -- "-${pgid}" >/dev/null 2>&1 || true +} + +site_lab_stop_codex() { + local pgid_file pgid + if [[ -d "${SITE_LAB_CODEX_RUNTIME}" ]]; then + while IFS= read -r -d '' pgid_file; do + pgid="$(<"${pgid_file}")" + site_lab_terminate_process_group "${pgid}" + done < <(find "${SITE_LAB_CODEX_RUNTIME}" -name pgid -type f -print0 2>/dev/null) + fi + rm -rf -- "${SITE_LAB_CODEX_RUNTIME}" +} diff --git a/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/lab-guard b/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/lab-guard new file mode 100644 index 0000000..9104667 --- /dev/null +++ b/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/lab-guard @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +# shellcheck disable=SC1091 +source /usr/local/lib/site-lab/common.sh + +export LC_ALL=C + +block() { + local reason="$1" + site_lab_lock_network || true + site_lab_set_state blocked + site_lab_write_issue "BLOCKED" "${reason}" "Exercise services are OFF." + printf 'BLOCKED: %s\n' "${reason}" >&2 + exit 1 +} + +[[ "${EUID}" -eq 0 ]] || block "lab-guard must run as root." +site_lab_lock_network +site_lab_set_state guarded + +site_lab_assert_live_ram || block "Boot with toram and nopersistence from the Live USB." +site_lab_assert_live_media_in_ram || + block "The Live medium was not copied to RAM. Do not remove the USB." + +site_lab_set_state waiting-for-usb +site_lab_write_issue \ + "REMOVE USB" \ + "1. Remove the Live USB now." \ + "2. Disable the internal SSD in UEFI if a disk remains visible." \ + "3. Connect the direct Ethernet cable." +/usr/local/bin/lab-console + +while [[ -n "$(site_lab_physical_disks)" ]]; do + if [[ -z "$(site_lab_usb_disks)" && "$(site_lab_state)" != "disk-blocked" ]]; then + site_lab_set_state disk-blocked + site_lab_write_issue \ + "DISK DETECTED - STOP" \ + "The Live USB is gone, but another physical disk is visible." \ + "Exercise services are OFF." \ + "Reboot and disable the internal SSD in UEFI." + /usr/local/bin/lab-console + fi + printf 'Waiting for all physical disks to disappear:\n%s\n' "$(site_lab_physical_disks)" + sleep 3 +done + +site_lab_set_state waiting-for-lan +site_lab_write_issue \ + "CONNECT LAN" \ + "No physical disk is visible." \ + "Connect the target directly to the Kali laptop." +/usr/local/bin/lab-console + +while ! site_lab_find_ethernet_with_carrier >/dev/null; do + sleep 2 +done + +/usr/local/sbin/lab-mode exercise --boot + +while sleep 2; do + site_lab_assert_no_physical_disks || + block "A physical disk appeared after the exercise started." +done diff --git a/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/lab-mode b/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/lab-mode new file mode 100644 index 0000000..2f2b0c8 --- /dev/null +++ b/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/lab-mode @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +# shellcheck disable=SC1091 +source /usr/local/lib/site-lab/common.sh + +export LC_ALL=C + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +[[ "${EUID}" -eq 0 ]] || die "Run lab-mode with sudo." + +mode="${1:-}" + +exercise_failed() { + local status="$?" + site_lab_lock_network || true + site_lab_set_state blocked + site_lab_write_issue \ + "BLOCKED" \ + "Exercise preflight failed. Services are OFF." \ + "Run: sudo lab-preflight" + exit "${status}" +} + +enter_exercise() { + trap exercise_failed ERR + + site_lab_assert_live_ram || die "This is not the non-persistent RAM Live system." + site_lab_assert_live_media_in_ram || die "The Live medium is not RAM-backed." + site_lab_assert_no_physical_disks || die "Disable internal storage and remove USB media." + + ethernet_interface="$(site_lab_find_ethernet_with_carrier)" || + die "Connect the direct Ethernet cable first." + + site_lab_stop_codex + /usr/local/sbin/lab-reset-runtime + + nmcli radio wifi off + nmcli networking on + + while IFS=: read -r device state; do + if [[ -n "${device}" && "${device}" != "${ethernet_interface}" && + "${device}" != "lo" && "${state}" == "connected" ]]; then + nmcli device disconnect "${device}" >/dev/null 2>&1 || true + fi + done < <(LC_ALL=C nmcli -t -f DEVICE,STATE device status) + + nmcli connection delete site-lab-ethernet >/dev/null 2>&1 || true + nmcli connection add \ + type ethernet \ + ifname "${ethernet_interface}" \ + con-name site-lab-ethernet \ + connection.autoconnect no \ + ipv4.method manual \ + ipv4.addresses 10.13.37.10/24 \ + ipv4.gateway "" \ + ipv4.dns "" \ + ipv4.never-default yes \ + ipv6.method disabled >/dev/null + nmcli connection up site-lab-ethernet >/dev/null + printf '%s\n' "${ethernet_interface}" > "${SITE_LAB_INTERFACE_FILE}" + + cat > /run/site-lab-dnsmasq.conf < /etc/nftables.conf </dev/null 2>&1 || true + nft flush ruleset + nmcli radio wifi on + + install -d -m 1777 "${SITE_LAB_CODEX_RUNTIME}" + site_lab_set_state maintenance + site_lab_write_issue \ + "MAINTENANCE" \ + "Lab services are OFF." \ + "Connect only to a personal tether with: nmtui" \ + "Start temporary Codex with: codex-maintenance" \ + "Return with: sudo lab-mode exercise" + + cat /etc/issue +} + +case "${mode}" in + exercise) + enter_exercise + ;; + maintenance) + enter_maintenance + ;; + status) + site_lab_state + ;; + *) + die "Usage: lab-mode {exercise|maintenance|status}" + ;; +esac diff --git a/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/lab-preflight b/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/lab-preflight new file mode 100644 index 0000000..2fbd849 --- /dev/null +++ b/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/lab-preflight @@ -0,0 +1,136 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +# shellcheck disable=SC1091 +source /usr/local/lib/site-lab/common.sh + +readonly target_url="http://10.13.37.10" +failures=0 +expected_mode="exercise" + +if [[ "${1:-}" == "--arming" ]]; then + expected_mode="arming" +elif [[ -n "${1:-}" ]]; then + printf 'Usage: lab-preflight [--arming]\n' >&2 + exit 1 +fi + +export LC_ALL=C + +pass() { + printf '[PASS] %s\n' "$1" +} + +fail() { + printf '[FAIL] %s\n' "$1" >&2 + failures=$((failures + 1)) +} + +check() { + local label="$1" + shift + if "$@" >/dev/null 2>&1; then + pass "${label}" + else + fail "${label}" + fi +} + +[[ "${EUID}" -eq 0 ]] || { + printf 'Run lab-preflight with sudo.\n' >&2 + exit 1 +} + +check "Live root uses toram and nopersistence" site_lab_assert_live_ram +check "Live medium is backed by RAM" site_lab_assert_live_media_in_ram +check "no physical disk is visible" site_lab_assert_no_physical_disks + +check "${expected_mode} mode is selected" \ + grep -Fxq "${expected_mode}" "${SITE_LAB_STATE_FILE}" + +if [[ -r "${SITE_LAB_INTERFACE_FILE}" ]]; then + ethernet_interface="$(<"${SITE_LAB_INTERFACE_FILE}")" + pass "lab Ethernet interface is recorded" +else + ethernet_interface="" + fail "lab Ethernet interface is recorded" +fi + +if [[ -n "${ethernet_interface}" ]] && + ip -4 -o address show dev "${ethernet_interface}" | grep -Fq '10.13.37.10/24'; then + pass "target IPv4 address exists" +else + fail "target IPv4 address exists" +fi + +if ip -4 route show default | grep -q .; then + fail "no IPv4 default route" +else + pass "no IPv4 default route" +fi + +if ip -6 route show default | grep -q .; then + fail "no IPv6 default route" +else + pass "no IPv6 default route" +fi + +if nmcli radio wifi | grep -Fxq disabled; then + pass "Wi-Fi radio is disabled" +else + fail "Wi-Fi radio is disabled" +fi + +unexpected_devices="$( + nmcli -t -f DEVICE,STATE device status | + awk -F: -v lab="${ethernet_interface}" \ + '$1 != lab && $1 != "lo" && $2 == "connected" { print $1 }' +)" +if [[ -n "${unexpected_devices}" ]]; then + fail "no other network device is connected" +else + pass "no other network device is connected" +fi + +if nmcli -g IP4.DNS,IP6.DNS device show | grep -q '[^[:space:]]'; then + fail "no DNS server is configured" +else + pass "no DNS server is configured" +fi + +check "Apache is active" systemctl is-active apache2.service +check "lab DHCP is active" systemctl is-active site-lab-dhcp.service +check "outbound firewall policy is drop" \ + bash -c "nft list chain inet site_lab output | grep -Fq 'policy drop'" +check "target homepage responds" curl --fail --silent --max-time 3 "${target_url}/" +check "participant guide responds" curl --fail --silent --max-time 3 "${target_url}/start/" +check "diagnostic tool responds" curl --fail --silent --max-time 3 "${target_url}/staff/network-check.php" +check "guide received a boot-specific instance" \ + bash -c "! grep -Fq '__LAB_INSTANCE_ID__' /srv/shop-site/guide/guide.mjs" + +if find /home /root -path '*/.codex/auth.json' -print -quit 2>/dev/null | grep -q .; then + fail "no persistent Codex auth.json exists" +else + pass "no persistent Codex auth.json exists" +fi + +if [[ -e "${SITE_LAB_CODEX_RUNTIME}" ]]; then + fail "temporary Codex runtime was removed" +else + pass "temporary Codex runtime was removed" +fi + +check "pgrep is available" command -v pgrep +if pgrep -x codex >/dev/null 2>&1 || pgrep -x codex-cli >/dev/null 2>&1; then + fail "no Codex maintenance process remains" +else + pass "no Codex maintenance process remains" +fi + +if ((failures > 0)); then + printf '\nPreflight failed: %d check(s).\n' "${failures}" >&2 + exit 1 +fi + +printf '\nPreflight passed. The diskless target is ready.\n' diff --git a/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/lab-reset-runtime b/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/lab-reset-runtime new file mode 100644 index 0000000..00fedca --- /dev/null +++ b/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/lab-reset-runtime @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +# shellcheck disable=SC1091 +source /usr/local/lib/site-lab/common.sh + +readonly site_root="/srv/shop-site" + +[[ "${EUID}" -eq 0 ]] || { + printf 'Run lab-reset-runtime with sudo.\n' >&2 + exit 1 +} + +[[ "${site_root}" == "/srv/shop-site" ]] || exit 1 +[[ -d "${SITE_LAB_SOURCE}/public" && -d "${SITE_LAB_SOURCE}/guide" ]] || { + printf 'Immutable lab source is missing.\n' >&2 + exit 1 +} + +rm -rf -- "${site_root}" +install -d -m 0755 \ + "${site_root}/public" \ + "${site_root}/guide" \ + "${site_root}/data" \ + "${site_root}/private" +cp -a "${SITE_LAB_SOURCE}/public/." "${site_root}/public/" +cp -a "${SITE_LAB_SOURCE}/guide/." "${site_root}/guide/" +install -m 0640 \ + "${SITE_LAB_SOURCE}/private/manager-note.txt" \ + "${site_root}/private/manager-note.txt" +install -m 0600 "${SITE_LAB_SOURCE}/root-proof.txt" /root/root-proof.txt + +lab_instance_id="$(cat /proc/sys/kernel/random/uuid)" +grep -Fq "__LAB_INSTANCE_ID__" "${site_root}/guide/guide.mjs" || { + printf 'Guide instance placeholder is missing.\n' >&2 + exit 1 +} +sed -i "s/__LAB_INSTANCE_ID__/${lab_instance_id}/g" "${site_root}/guide/guide.mjs" +printf '%s\n' "${lab_instance_id}" > /run/site-takeover-instance-id + +printf '%s\n' '本日は通常どおり営業しています。' > "${site_root}/data/announcement.txt" + +chown -R root:root "${site_root}/public" "${site_root}/guide" +find "${site_root}/public" "${site_root}/guide" -type d -exec chmod 0755 {} + +find "${site_root}/public" "${site_root}/guide" -type f -exec chmod 0644 {} + +chown root:www-data "${site_root}/data" "${site_root}/data/announcement.txt" +chmod 0775 "${site_root}/data" +chmod 0664 "${site_root}/data/announcement.txt" +chown root:www-data "${site_root}/private" "${site_root}/private/manager-note.txt" +chmod 0750 "${site_root}/private" +chmod 0640 "${site_root}/private/manager-note.txt" +chmod 0600 /root/root-proof.txt diff --git a/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/site-maintenance b/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/site-maintenance new file mode 100644 index 0000000..ac80146 --- /dev/null +++ b/labs/site-takeover/live/config/includes.chroot/usr/local/sbin/site-maintenance @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +requested_action="${1:-is-active}" + +# Intentionally vulnerable for the optional privilege-escalation chapter. +# A real helper must use an allowlist and invoke systemctl without a shell. +/bin/bash -c "/usr/bin/systemctl ${requested_action} apache2" diff --git a/labs/site-takeover/live/config/package-lists/site-takeover.list.chroot b/labs/site-takeover/live/config/package-lists/site-takeover.list.chroot new file mode 100644 index 0000000..952b78d --- /dev/null +++ b/labs/site-takeover/live/config/package-lists/site-takeover.list.chroot @@ -0,0 +1,23 @@ +apache2 +ca-certificates +console-setup +curl +dnsmasq-base +firmware-atheros +firmware-iwlwifi +firmware-realtek +iproute2 +iputils-ping +libapache2-mod-php +live-boot +live-config +live-tools +network-manager +nftables +nodejs +npm +php-cli +procps +rsync +sudo +util-linux diff --git a/labs/site-takeover/operator/COMPANY-SETUP.md b/labs/site-takeover/operator/COMPANY-SETUP.md new file mode 100644 index 0000000..cfe970a --- /dev/null +++ b/labs/site-takeover/operator/COMPANY-SETUP.md @@ -0,0 +1,157 @@ +# 会社のWindowsだけで準備する + +標的ノートのWindowsを残したまま、会社で検証済みISOの取得、USB検査、USB作成まで +完結させる。ソースを変更する場合だけCodexとGitHub Actionsへ進む。会社LANや会社Wi-Fi +は使わず、個人テザリングだけを使う。 + +この手順では、ISOが生成されたことと実機で使えることを分けて扱う。Actions成功だけでは当日用USBにしない。 + +## 1. 完成済みISOを使う最短ルート + +このルートはGit、Codex、clone、ローカルビルドを必要としない。PowerShellでUSB作成に +必要な3つだけを導入する。 + +```powershell +winget install --id GitHub.cli --exact --accept-package-agreements --accept-source-agreements +winget install --id HaraldBoegeholz.h2testw --exact --accept-package-agreements --accept-source-agreements +winget install --id Rufus.Rufus --exact --accept-package-agreements --accept-source-agreements +``` + +`winget`が見つからない場合は、Microsoft Storeの「アプリ インストーラー」を更新する。 +導入後はPowerShellを開き直し、本人がprivate GitHubへログインする。運営文書は +相対リンクを壊さない `operator-kit.zip` として一括取得する。 + +```powershell +gh auth login --hostname github.com --git-protocol https --web + +New-Item -ItemType Directory -Force C:\lab\bootstrap +Set-Location C:\lab\bootstrap +gh release download site-takeover-live-v0.1.0-rc1 ` + --repo skjshr/tgtsec ` + --pattern site-takeover-operator-kit.zip ` + --pattern site-takeover-operator-kit.zip.sha256 + +$kitName = 'site-takeover-operator-kit.zip' +$checksumName = "$kitName.sha256" +$line = @(Get-Content -LiteralPath ".\$checksumName" | + Where-Object { $_ -match '\s+\*?site-takeover-operator-kit\.zip$' }) +if ($line.Count -ne 1) { + throw 'operator kitのSHA-256行が一つではありません。' +} +$expected = (($line[0].Trim() -split '\s+')[0]).ToUpperInvariant() +$actual = (Get-FileHash -LiteralPath ".\$kitName" -Algorithm SHA256).Hash.ToUpperInvariant() +if ($expected -notmatch '^[0-9A-F]{64}$' -or $actual -cne $expected) { + throw "operator kitのSHA-256不一致: expected=$expected actual=$actual" +} +Write-Host "operator kit SHA-256 OK: $actual" + +$operatorRoot = 'C:\lab\site-takeover-operator-kit' +if (Test-Path -LiteralPath $operatorRoot) { + throw "$operatorRoot は既に存在します。古い一式を別名へ移してから再実行してください。" +} +Expand-Archive -LiteralPath ".\$kitName" -DestinationPath $operatorRoot +Set-Location $operatorRoot + +.\labs\site-takeover\operator\company-bootstrap.ps1 -SelfTest +.\labs\site-takeover\operator\company-bootstrap.ps1 ` + -DownloadRelease ` + -ConfirmPersonalTether +``` + +最後のコマンドはdraft/prerelease、対象commit、ISO、SHA-256、BIOS/UEFI記録を照合し、 +次へ保存する。 + +```text +C:\lab\site-takeover-release +``` + +`SHA-256 verified`と`BIOS and UEFI boot entries are present`の両方が出れば取得合格である。 +スクリプトはUSBをフォーマットせず、ISOも書き込まない。 + +## 2. USBを検査して書き込む + +`C:\lab\site-takeover-operator-kit\labs\site-takeover\operator\USB.md`を開き、 +接続したUSBの物理IDと容量を確認する。現在のBUFFALO USBは警告歴があるため、H2testwの +`all available space`を`Write + Verify`してエラー0件の場合だけRufusへ進む。 +Rufusでは次のISOをDDイメージモードで書き込む。 + +```text +C:\lab\site-takeover-release\site-takeover-live-amd64.iso +``` + +書き込み後は`C:\lab\site-takeover-operator-kit\METHODS.md`を索引にし、起動順序と +Windowsへの戻し方は +`C:\lab\site-takeover-operator-kit\labs\site-takeover\operator\DAY-OF.md`を使う。 +Kaliの準備と完全解答も同じ階層を保ったoperator kit内に揃う。 + +## 3. ソースを変更する場合だけcloneする + +完成済みISOをそのまま使う場合、この節は不要である。変更が必要な場合だけGitとCodexを +導入し、本人がそれぞれ認証する。 + +```powershell +winget install --id Git.Git --exact +irm https://chatgpt.com/codex/install.ps1 | iex +``` + +PowerShellを開き直してから実行する。 + +```powershell +codex login +Set-Location C:\lab +gh repo clone skjshr/tgtsec +Set-Location tgtsec +git fetch origin +git switch feat/live-usb-b2r +.\labs\site-takeover\operator\company-bootstrap.ps1 -SelfTest +``` + +privateリポジトリなので、未認証のraw URLから構築資材を取得しない。 + +## 4. ソースを変える場合だけCodexとActionsを使う + +```powershell +Set-Location C:\lab\tgtsec +codex +``` + +1. Codexには `PROJECT_CONSTITUTION.md`、`DESIGN.md`、`TASK_CONTRACT.md`を先に読ませる。 +2. テスト済みの変更だけを作業ブランチへcommitしてpushする。 +3. `feat/live-usb-b2r`をpushすると、初回用の`Build Live ISO draft release`が自動で動く。Actions画面で同じcommitの実行を確認する。 +4. このworkflowが`main`へ入った後は、Actions画面から作業ブランチとdraft tagを選んで手動実行できる。 +5. [GitHubの仕様](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow)上、workflow_dispatchはworkflowファイルがdefault branchに存在するまで表示されない。未mergeの段階で手動実行ボタンがないことを失敗と取り違えず、feature branchのpush実行を見る。 +6. push実行がない、または失敗した場合は「ISO未生成」と扱い、ActionsログをCodexへ渡して直す。 + +ローカルの `live/build.sh` が生成する配布物の名前は次の3つである。Actionsとdraft prereleaseも同じ名前を使う。 + +```text +site-takeover-live-amd64.iso +site-takeover-live-amd64.iso.sha256 +site-takeover-live-amd64.boot.txt +``` + +## 5. USBへ進めるゲート + +次の証拠が全て揃った場合だけ [USB.md](USB.md) へ進む。 + +| 状態 | 必要な証拠 | +|---|---| +| 今回のISO | draft prereleaseの対象commit、ISO、`.iso.sha256`、`.boot.txt`が一致し、SHA-256照合が通る | +| VM検証済み | USB取り外し待ち、ディスク拒否、LAN待ち、Web改ざん、再起動初期化の実測記録がある | +| 配布候補 | private draft prereleaseに上記3ファイルと対象commitが記録されている | +| 再ビルド自動化 | GitHub Actionsの課金/利用上限を直し、今後の変更commitからworkflowを通す。今回の検証済みRCを使うだけなら別ゲート | +| USB合格 | 全容量の書き込み・読み戻し検査がエラー0件である | +| 実機合格 | 対象ノートでUSB起動、SSD非表示、USB取り外し、直結LANを実測している | + +未確認の欄を推測で埋めない。古いDebian DVD、Ventoyキット、Actionsの未検証artifactを当日用ISOの代わりにしない。 + +## 6. 共有Windowsならログアウトする + +自分専用ではないWindowsを使った場合は、作業終了後に本人がログアウトする。 + +```powershell +codex logout +gh auth logout --hostname github.com +``` + +`C:\lab\tgtsec`を残すか削除するかはPC管理者の方針に従う。スクリプトはログアウトも削除も自動実行しない。 diff --git a/labs/site-takeover/operator/DAY-OF.md b/labs/site-takeover/operator/DAY-OF.md new file mode 100644 index 0000000..11561d6 --- /dev/null +++ b/labs/site-takeover/operator/DAY-OF.md @@ -0,0 +1,137 @@ +# 当日の進め方 + +参加者が最初にすることは、Kaliのブラウザで店のWebサイトを見ること。USB起動、物理ディスク拒否、直結LANは、参加者が来る30分前に運営担当が実測する。 + +## 開始前の合格条件 + +- [USB.md](USB.md)の物理検証記録に、USB全容量検査とISO SHA-256のPASSがある +- 標的ノートのWindows、SSD、パーティションを変更していない +- BitLockerが有効なら回復キーへアクセスでき、UEFI変更前に保護を一時停止している +- Kaliは [KALI-PREFLIGHT.md](KALI-PREFLIGHT.md) の「ケーブルを挿す前」まで完了している +- 会社LAN、会社Wi-Fi、VPN、外付けディスクが2台から外れている + +空欄や未実測をPASSとして扱わない。 + +### WindowsとBitLockerを保護する + +最初の実機試験では、Windowsを終了する前に管理者PowerShellで状態を確認する。 + +```powershell +Get-BitLockerVolume -MountPoint C: +``` + +`ProtectionStatus`が`On`なら、回復キーへ本人がアクセスできることを先に確認し、今回のUEFI変更中だけ保護を一時停止する。 + +```powershell +Suspend-BitLocker -MountPoint C: -RebootCount 2 +``` + +この扱いはMicrosoftの +[BitLocker保護の一時停止手順](https://learn.microsoft.com/en-us/troubleshoot/windows-client/windows-security/suspend-bitlocker-protection-non-microsoft-updates) +に従う。一時停止は復号ではないが、Windowsへ戻ったら必ず再開する。 + +回復キーがない、会社の方針で一時停止できない、または内蔵SSDを無効化する項目がUEFIにない場合は、実機試験を開始しない。ディスク安全ゲートを弱めて代用しない。 + +## Live USBから演習開始まで + +順番を変えない。標的はLANケーブルがつながるまで演習開始状態にならない。 + +1. 標的ノートをWindowsから完全に終了する。 +2. LANケーブルと外付けディスクを外したまま、UEFIで内蔵SSDを無効化する。 +3. Live USBを挿し、Debianの起動画面で `Live system (amd64)` を選ぶ。 +4. `REMOVE USB`(USBを抜く)が表示されるまで待つ。 +5. Live USBを物理的に抜く。USBが残っている間は先へ進まない。 +6. `CONNECT LAN`(LANを接続)が表示されたら、ここで初めてKaliとのLANケーブルを挿す。 +7. `EXERCISE READY`(演習開始)と `10.13.37.10` が表示されるまで待つ。 +8. Kaliで事前確認の残りを実行し、サイトとガイドを別タブで開く。 + +```text +http://10.13.37.10/ +http://10.13.37.10/start/ +``` + +`BLOCKED`、物理ディスク一覧、USB取り外し失敗のいずれかが出たら、演習サービスは停止したままである。USBやディスクを挿して解除せず、電源を終了してUEFI設定から確認する。 + +署名済みISOでも機種固有のUEFI設定で起動を拒否される場合がある。BitLocker回復キーを確認せずSecure Bootを変更しない。変更が必要なら実機検証項目として別日に扱う。 + +参加者へ見せるのはKaliだけ。標的ノートのキーボードは運営担当だけが触る。 + +## 最初の説明 + +> 右のノートPCで小さな店のWebサイトが動いています。右のPCには触らず、Kaliだけを使って、トップページの「本日のお知らせ」を自分たちのチーム名へ変えてください。触ってよいのはこの2台だけです。迷ったらガイドで次に知りたいことを選んでください。 + +コマンドインジェクションやrootという言葉は最初に教えない。店のサイトが変わるまでは、参加者が見た事実を言葉にする手助けだけをする。 + +## 90分の配分 + +| 時間 | やること | +|---|---| +| 0〜10分 | 触ってよい範囲とゴールを共有し、店のサイトを見る | +| 10〜25分 | 相手PCの入口と、公開されていない管理ページを探す | +| 25〜50分 | 入力欄からOSの利用者名と置き場所を調べる | +| 50〜65分 | チーム名を書き込み、トップページを再読み込みする | +| 65〜75分 | 入力、シェル、限定利用者、元データの関係を説明する | +| 75〜85分 | 余裕があれば非公開メモか管理者権限へ進む | +| 85〜90分 | 参加者自身の言葉で何が起きたか話してもらう | + +50分時点で管理ページに届いていなければ、ガイドの3段階目のヒントを開く。必須ゴールを飛ばしてrootへ進めない。 + +## 標的上のCodexで故障を調べる + +これは参加者向けヒントではなく、Webサービス自体が壊れた場合の運営用保守モードである。 +演習中のままテザリングへつながない。 + +1. 参加者の操作を止め、標的とKaliのLANケーブルを抜く。 +2. 標的コンソールで`sudo lab-mode maintenance`を実行する。 +3. `MAINTENANCE`と`Lab services are OFF`を確認する。 +4. `sudo nmtui`で、本人の個人テザリングだけへ接続する。 +5. 初回だけ`codex-maintenance login --device-auth`を実行し、表示された本人確認を別端末のブラウザで完了する。 +6. `cd /srv/shop-site`、`codex-maintenance`の順に実行して、実行中の一時コピーを読み取り調査する。rootでは起動しない。終了はCodex内の終了操作か`Ctrl+C`を使う。 +7. テザリング接続を`sudo nmtui`で切り、`nmcli radio wifi off`を実行する。 +8. 標的とKaliの直結LANだけを挿し直す。 +9. `sudo lab-mode exercise`を実行し、表示されるpreflightが全て`PASS`になり、`EXERCISE READY`へ戻ることを確認する。 + +Codexの認証、履歴、作業領域は`/run`だけを使い、exerciseへ戻る処理がCodexの +プロセスと認証領域を削除する。`/srv/shop-site`への変更も初期状態から作り直すため、 +お知らせと参加者ガイドの進捗は0へ戻る。恒久修正は標的上で行わず、会社Windowsの +Gitリポジトリへ反映して新しいISOを作る。 + +## 詰まったとき + +### どちらのページも開かない + +1. 標的画面が `EXERCISE READY` のままか確認する。 +2. LAN端子のリンクランプを見る。 +3. Kaliで `ip -br address` と `ip -4 route show default` を見る。 +4. Kaliに `10.13.37.x` が付かない場合だけ、事前確認表の固定IPへ切り替える。 + +### 店のサイトだけ開く + +ガイドは同じWebサーバの `/start/` にある。 + +```text +http://10.13.37.10/start/ +``` + +### 結果を貼っても先へ進まない + +実行したコマンドと、その直後の出力をまとめて貼る。プロンプトの1行だけでは判定できない。ガイドが示す「確認する行」をもう一度見る。 + +### 参加者がターミナルで止まる + +運営担当が代わりに入力しない。「いま知りたいのは入口、利用者、置き場所のどれか」と聞き、本人にガイドの選択肢を選んでもらう。貼り付け方法だけは教えてよい。 + +## 終了してWindowsへ戻す + +順番を変えない。SSDを無効のままWindowsを起動しようとしない。 + +1. LANケーブルを抜く。 +2. 運営担当が標的コンソールで `sudo poweroff` を実行し、電源が切れるまで待つ。 +3. Live USBが抜けていることを確認する。 +4. 標的をUEFIへ起動し、内蔵SSDを再び有効化する。 +5. UEFI設定を保存して再起動する。 +6. Windowsが通常どおり起動することを確認する。 +7. BitLockerを一時停止した場合は、管理者PowerShellで `Resume-BitLocker -MountPoint C:` を実行し、`ProtectionStatus`が`On`へ戻ったことを確認する。 +8. 借用Kaliに固定IPを作った場合は、事前確認表の削除手順を実行する。 + +お知らせだけを戻す片付け操作は行わない。Live OSの電源終了を初期化とし、次回はSSDを再び無効化してLive USBから起動する。 diff --git a/labs/site-takeover/operator/KALI-PREFLIGHT.md b/labs/site-takeover/operator/KALI-PREFLIGHT.md new file mode 100644 index 0000000..485e29f --- /dev/null +++ b/labs/site-takeover/operator/KALI-PREFLIGHT.md @@ -0,0 +1,126 @@ +# Kali事前確認 + +借用KaliにLive USBを挿さない。演習中は標的ノートとの直結LANだけを使い、KaliのWi-Fi、会社LAN、VPNを切る。 + +## Windows上のKali VMを使う場合 + +設定中は標的とのLANケーブルも抜いたままにする。VirtualBoxを開く前に、Windows側の +Wi-Fi、VPN、モバイルホットスポット、インターネット接続の共有を切り、ドックや別の +LANケーブルも外す。会社LANへつながったままVMを起動しない。標的画面が +`CONNECT LAN`になった後にだけ、Kali VMを使うノートと標的ノートを直結する。 + +管理者PowerShellは不要である。Windows側で、標的へつないだEthernet以外に +通信中の接続とデフォルトゲートウェイがないことを読む。 + +```powershell +Get-NetAdapter | Where-Object Status -eq 'Up' +Get-NetIPConfiguration | + Where-Object { $null -ne $_.IPv4DefaultGateway } | + Format-Table InterfaceAlias,IPv4Address,IPv4DefaultGateway +``` + +2つ目のコマンドが何も返さない状態にする。VirtualBoxのKali VM設定は次に固定する。 + +- アダプター1だけを有効にする +- 接続方法は`ブリッジアダプター` +- 名前は、標的へ直結した物理Ethernetだけを選ぶ +- `ケーブル接続`を有効、プロミスキャスモードは`拒否` +- アダプター2〜4は無効 +- NAT、NATネットワーク、ホストオンリー、内部ネットワークを追加しない + +USB Ethernetアダプターを使う場合も、Windowsに見えているその物理アダプターへ +ブリッジする。Kali VMを起動した後は、下のゲスト内確認を全て行う。Kali側に +デフォルト経路が残る場合は、VirtualBox設定かWindows側の接続が違うため中止する。 + +終了時は標的とのLANを抜いてKali VMを停止してから、VirtualBoxとWindowsの接続を +借用元の状態へ戻す。 + +## ケーブルを挿す前 + +有線LANの機器名を調べる。 + +```bash +nmcli device status +``` + +借用元の接続設定は削除せず、Wi-FiとVPNを一時的に切る。 + +```bash +nmcli radio wifi off +ip -4 route show default +``` + +`ip -4 route show default`が何も返さないことを確認する。残る場合は、その接続が会社LANやVPNではないか確認して切る。 + +この時点では標的とのLANケーブルを挿さない。標的画面の `CONNECT LAN` を待つ。 + +## `CONNECT LAN`の後 + +標的ノートとKaliをLANケーブルで直結する。直結した有線LANに次の範囲のアドレスが付けばよい。 + +```bash +ip -br address +``` + +```text +10.13.37.100〜10.13.37.150 +``` + +経路、デフォルト経路、必要な道具、2つのページを確認する。 + +```bash +ip route get 10.13.37.10 +ip -4 route show default +command -v firefox nmap curl +curl --noproxy '*' --fail http://10.13.37.10/ +curl --noproxy '*' --fail http://10.13.37.10/start/ +``` + +次を全て満たせば合格。 + +- `10.13.37.10`への経路が直結した有線LANを向いている +- デフォルト経路が表示されない +- `firefox`、`nmap`、`curl`の場所が表示される +- 2回の`curl`がHTMLを返す +- Firefoxで店のサイトとガイドが開く +- インターネット、会社LAN、VPNへつながっていない +- KaliへLive USBを挿さなくても進められる + +確認対象は `10.13.37.10` だけである。会社のIPアドレスや第三者サイトをスキャンしない。 + +## DHCPでアドレスが付かない場合 + +`<有線LAN名>`を `nmcli device status` で見た名前へ置き換える。 + +```bash +sudo nmcli connection add \ + type ethernet \ + ifname '<有線LAN名>' \ + con-name site-lab-attacker \ + ipv4.method manual \ + ipv4.addresses 10.13.37.100/24 \ + ipv4.never-default yes \ + ipv4.ignore-auto-dns yes \ + ipv6.method disabled +sudo nmcli connection up site-lab-attacker +``` + +今回は攻撃側を1台に限定する。固定IPを作ったら、終了時に必ず削除する。 + +## Firefoxだけ開かない場合 + +組織用プロキシ設定が残っている可能性がある。Firefoxのネットワーク設定を演習中だけ「プロキシーを使用しない」にする。終わったら借用時の設定へ戻す。 + +## 終了時 + +固定IPを作った場合だけ削除する。 + +```bash +sudo nmcli connection delete site-lab-attacker +``` + +標的とのLANケーブルを抜く。Wi-FiとVPNは借用元の運用に従って戻す。 + +```bash +nmcli radio wifi on +``` diff --git a/labs/site-takeover/operator/ROOT-BONUS.md b/labs/site-takeover/operator/ROOT-BONUS.md new file mode 100644 index 0000000..1b26461 --- /dev/null +++ b/labs/site-takeover/operator/ROOT-BONUS.md @@ -0,0 +1,37 @@ +# 管理者権限ボーナス + +これは運営担当用の解答。参加者向けガイドへ貼らない。 + +必須ゴールを終え、参加者が「最初の命令は `www-data` という限定利用者で動いた」と説明できた場合だけ使う。 + +## 仕掛け + +`www-data` は次の保守コマンドをパスワードなしで実行できる。 + +```text +sudo /usr/local/sbin/site-maintenance <引数> +``` + +保守コマンドは引数を確認せず、rootのシェルへ渡している。このラボ専用に入れた二つ目の脆弱性だ。 + +## 観察の順番 + +まず権限を確認する。 + +```text +127.0.0.1; sudo -l +``` + +次に、設定を誤った保守コマンドから、管理者しか読めない証拠を直接表示する。管理ページの入力欄へ次を入れる。 + +```text +127.0.0.1; sudo /usr/local/sbin/site-maintenance 'is-active apache2; cat /root/root-proof.txt; #' +``` + +`ROOT BONUS COMPLETE` が表示されれば成功。 + +## 説明 + +最初の脆弱性で得たのはWebサーバ用の狭い権限だった。そこから、設定を誤った保守コマンドをrootとして動かしたので、PC全体を変更できる権限まで広がった。 + +この仕掛けは使い捨ての標的ノート専用。実運用のPCへ同じsudoers設定や保守コマンドを入れてはいけない。 diff --git a/labs/site-takeover/operator/USB.md b/labs/site-takeover/operator/USB.md new file mode 100644 index 0000000..77ca886 --- /dev/null +++ b/labs/site-takeover/operator/USB.md @@ -0,0 +1,139 @@ +# Live USBを検査して作る + +このUSBは、標的ノートを一時的な脆弱Webサーバとして起動するために使う。DebianをSSDへインストールせず、Windows、パーティション、BitLockerを変更しない。 + +現在のBUFFALO USBはWindowsで `Full Repair Needed` と報告されている。正常な16GB以上のUSBへ交換するのが第一選択である。同じUSBを使う場合は、以下の全容量検査でエラー0件になった場合だけ採用する。 + +## 1. 破壊操作の前提 + +ここからのUSB検査とISO書き込みは、対象USBの全データを消す。 + +- USB内のファイルが不要だと本人が確認する +- 内蔵SSD、外付けSSD、別のUSBを可能な限り外す +- 管理者PowerShellで物理ディスク番号、型番、シリアル、バイト単位の容量を二人で読む +- 表示された同一性確認文字列を本人が入力した場合だけ初期化する + +`company-bootstrap.ps1`はUSBを読み取って表示するだけであり、この破壊操作は行わない。 + +## 2. 全容量検査用にUSBを初期化する + +先に [COMPANY-SETUP.md](COMPANY-SETUP.md) のwinget手順で導入したH2testwを使う。 +USBへツールを保存しない。H2testwが導入されていない場合は、この文書内で別経路から +取得せず会社準備へ戻る。 + +USBだけを挿し、管理者PowerShellで次を実行する。 + +```powershell +Get-Disk | + Sort-Object Number | + Format-Table Number,FriendlyName,SerialNumber,UniqueId,BusType,Size,HealthStatus,OperationalStatus + +$UsbDiskNumber = [int](Read-Host '消去するUSBのDisk Number') +$usb = Get-Disk -Number $UsbDiskNumber + +if ($usb.BusType -ne 'USB' -or $usb.IsBoot -or $usb.IsSystem) { + throw 'USB以外、起動ディスク、システムディスクは消去できません。' +} +if ($usb.IsOffline -or $usb.IsReadOnly) { + throw 'オフラインまたは読み取り専用です。解除せず、このUSBを交換してください。' +} + +$serial = ([string]$usb.SerialNumber).Trim() +if ([string]::IsNullOrWhiteSpace($serial)) { + $serial = ([string]$usb.UniqueId).Trim() +} +if ([string]::IsNullOrWhiteSpace($serial)) { + throw 'シリアルとUniqueIdを確認できないため、このUSBは消去しません。' +} +$identity = 'ERASE|DISK={0}|MODEL={1}|SERIAL={2}|BYTES={3}' -f ` + $usb.Number,$usb.FriendlyName,$serial,$usb.Size + +Write-Host $identity +$typed = Read-Host '本当にこのUSBを消去する場合だけ、上の1行を正確に入力' +if ($typed -cne $identity) { + throw '一致しないため、何も変更せず中止しました。' +} + +Clear-Disk -Number $UsbDiskNumber -RemoveData -RemoveOEM -Confirm:$true +Initialize-Disk -Number $UsbDiskNumber -PartitionStyle GPT +$partition = New-Partition -DiskNumber $UsbDiskNumber -UseMaximumSize -AssignDriveLetter +$volume = Format-Volume -Partition $partition -FileSystem exFAT ` + -NewFileSystemLabel USB_HEALTH_TEST -Confirm:$false + +Write-Host ('H2testw target: {0}:\' -f $volume.DriveLetter) +``` + +`Clear-Disk`の確認でも、対象番号をもう一度見る。確認文字列が空、型番が不明、容量が予想と違う場合は続けない。 + +## 3. 全領域を書いて読み戻す + +1. H2testwを起動し、Englishを選ぶ。 +2. `Select target`でPowerShellが表示した `USB_HEALTH_TEST` のドライブ文字だけを選ぶ。 +3. `all available space`を選ぶ。 +4. `Write + Verify`を開始し、完了までUSBを抜かない。 +5. `New-Item -ItemType Directory -Force C:\lab\usb-evidence` をPowerShellで実行する。 +6. 結果をテキストで `C:\lab\usb-evidence\h2testw.txt` に保存する。 + +合格は「全available spaceのWriteとVerifyが完了」「data lost/corrupted/overwrittenが0」「errorが0」の全てを満たす場合だけである。容量が製品表示から大きく不足する、中断する、1件でもエラーが出る場合はUSBを交換する。警告歴のあるUSBを再試験だけで当日の唯一の媒体にはしない。 + +H2testwのテストファイルは削除しなくてよい。後のRufusがUSB全体を上書きする。 + +## 4. ISOと実際のチェックサムを照合する + +private draft prereleaseから、同じリリースにある次の3ファイルを同じ空フォルダへ保存する。 + +```text +site-takeover-live-amd64.iso +site-takeover-live-amd64.iso.sha256 +site-takeover-live-amd64.boot.txt +``` + +そのフォルダでPowerShellを実行する。 + +```powershell +$isoName = 'site-takeover-live-amd64.iso' +$checksumName = 'site-takeover-live-amd64.iso.sha256' +$lines = @(Get-Content ".\$checksumName" | Where-Object { + $_ -match '\s+\*?site-takeover-live-amd64\.iso$' +}) +if ($lines.Count -ne 1) { + throw 'チェックサムファイルに対象ISOが1件だけ記載されていません。' +} + +$expected = (($lines[0].Trim() -split '\s+')[0]).ToUpperInvariant() +$actual = (Get-FileHash ".\$isoName" -Algorithm SHA256).Hash.ToUpperInvariant() +if ($expected -notmatch '^[0-9A-F]{64}$' -or $actual -cne $expected) { + throw "SHA-256不一致: expected=$expected actual=$actual" +} +Write-Host "SHA-256 OK: $actual" +``` + +`SHA-256 OK`が出ない場合はUSBへ書き込まない。`.boot.txt`はビルド時にBIOSとUEFIの両起動項目を検査した記録であり、実機起動成功の代わりにはならない。 + +## 5. RufusでLive ISOを書き込む + +1. [COMPANY-SETUP.md](COMPANY-SETUP.md) のwinget手順で導入したRufusを開く。 +2. 「デバイス」が全容量検査で記録したUSBの型番と容量に一致することを二人で確認する。 +3. `site-takeover-live-amd64.iso`を選ぶ。 +4. hybrid imageの方式を聞かれたらDDイメージモードを選ぶ。 +5. Rufusの最終警告でも対象USBを確認し、本人が開始する。 +6. 完了後にWindowsからフォーマットを求められてもキャンセルする。 +7. USBを安全に取り外す。 + +これで「書き込み済み」であり、「実機検証済み」ではない。[DAY-OF.md](DAY-OF.md)の実機確認が合格して初めて当日用になる。 + +## 物理検証記録 + +次を実測して記録する。空欄は未検証を意味する。 + +```text +実施日: +USB Disk Number / 型番 / シリアル / 容量: +H2testw Write + Verify: PASS / FAIL +ISO SHA-256: PASS / FAIL +Rufus DD write: PASS / FAIL +対象ノート型番: +UEFIでSSD無効化: PASS / FAIL +USB取り外し後のdiskless判定: PASS / FAIL +直結LANとWeb表示: PASS / FAIL +``` diff --git a/labs/site-takeover/operator/company-bootstrap.ps1 b/labs/site-takeover/operator/company-bootstrap.ps1 new file mode 100644 index 0000000..3862849 --- /dev/null +++ b/labs/site-takeover/operator/company-bootstrap.ps1 @@ -0,0 +1,393 @@ +[CmdletBinding()] +param( + [switch]$Install, + [switch]$ConfirmPersonalTether, + [switch]$DownloadRelease, + [switch]$SelfTest +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repository = 'skjshr/tgtsec' +$branch = 'feat/live-usb-b2r' +$releaseTag = 'site-takeover-live-v0.1.0-rc1' +$releaseDirectory = 'C:\lab\site-takeover-release' +$releaseAssetNames = @( + 'site-takeover-live-amd64.iso' + 'site-takeover-live-amd64.iso.sha256' + 'site-takeover-live-amd64.boot.txt' +) +$codexInstaller = 'https://chatgpt.com/codex/install.ps1' +$toolSpecs = @( + [pscustomobject]@{ Name = 'Git'; Command = 'git'; WingetId = 'Git.Git'; VersionArgs = @('--version') } + [pscustomobject]@{ Name = 'GitHub CLI'; Command = 'gh'; WingetId = 'GitHub.cli'; VersionArgs = @('--version') } + [pscustomobject]@{ Name = 'Codex CLI'; Command = 'codex'; WingetId = $null; VersionArgs = @('--version') } +) +$nextCommands = @( + 'gh auth login --hostname github.com --git-protocol https --web' + 'codex login' + 'New-Item -ItemType Directory -Force C:\lab' + 'Set-Location C:\lab' + "gh repo clone $repository" + 'Set-Location tgtsec' + 'git fetch origin' + "git switch $branch" + 'codex' +) + +function Write-Section { + param([Parameter(Mandatory)][string]$Title) + Write-Host '' + Write-Host "== $Title ==" +} + +function Write-Check { + param( + [Parameter(Mandatory)][ValidateSet('OK', 'WARN', 'INFO')][string]$State, + [Parameter(Mandatory)][string]$Message + ) + Write-Host "[$State] $Message" +} + +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-ToolStatus { + param([Parameter(Mandatory)]$Spec) + + $resolved = Get-Command $Spec.Command -ErrorAction SilentlyContinue + if ($null -eq $resolved) { + return [pscustomobject]@{ + Spec = $Spec + Installed = $false + Version = $null + } + } + + $versionOutput = @(& $resolved.Source @($Spec.VersionArgs) 2>&1) + $versionLine = ($versionOutput | Select-Object -First 1 | Out-String).Trim() + return [pscustomobject]@{ + Spec = $Spec + Installed = $true + Version = $versionLine + } +} + +function Get-WingetInstallArguments { + param([Parameter(Mandatory)][string]$PackageId) + return @( + 'install', + '--id', $PackageId, + '--exact', + '--accept-package-agreements', + '--accept-source-agreements' + ) +} + +function Install-WingetTool { + param([Parameter(Mandatory)]$Spec) + + $arguments = Get-WingetInstallArguments -PackageId $Spec.WingetId + Write-Host "Installing $($Spec.Name) with winget..." + & winget @arguments + if ($LASTEXITCODE -ne 0) { + throw "winget could not install $($Spec.Name) (exit $LASTEXITCODE)." + } +} + +function Install-CodexCli { + Write-Host "Installing Codex CLI from $codexInstaller ..." + $installerSource = Invoke-RestMethod -Uri $codexInstaller + if ([string]::IsNullOrWhiteSpace([string]$installerSource)) { + throw 'The Codex installer download was empty.' + } + & ([scriptblock]::Create([string]$installerSource)) +} + +function Get-VerifiedRelease { + $releaseJson = @( + & gh release view $releaseTag ` + --repo $repository ` + --json isDraft,isPrerelease,targetCommitish,assets 2>&1 + ) + if ($LASTEXITCODE -ne 0) { + throw "GitHub release metadata could not be read: $($releaseJson -join ' ')" + } + + $release = ($releaseJson -join "`n") | ConvertFrom-Json + if (-not $release.isDraft -or -not $release.isPrerelease) { + throw "Release $releaseTag is not both draft and prerelease." + } + if ([string]$release.targetCommitish -notmatch '^[0-9a-f]{40}$') { + throw "Release $releaseTag does not target an exact commit." + } + + $availableAssets = @($release.assets | ForEach-Object { [string]$_.name }) + foreach ($assetName in $releaseAssetNames) { + if ($availableAssets -notcontains $assetName) { + throw "Release $releaseTag is missing $assetName." + } + } + + return $release +} + +function Download-VerifiedRelease { + if (-not $ConfirmPersonalTether) { + throw 'Release download was blocked. Confirm a personal tether, then pass -ConfirmPersonalTether.' + } + if ($Install) { + throw 'Run -Install and -DownloadRelease as separate commands, reopening PowerShell between them.' + } + if ($null -eq (Get-Command gh -ErrorAction SilentlyContinue)) { + throw 'GitHub CLI is required. Install it first, reopen PowerShell, and run gh auth login.' + } + + & gh auth status --hostname github.com *> $null + if ($LASTEXITCODE -ne 0) { + throw 'GitHub CLI is not logged in. Run gh auth login --hostname github.com --git-protocol https --web.' + } + + Write-Section -Title 'Verified draft release download' + $release = Get-VerifiedRelease + + if (Test-Path -LiteralPath $releaseDirectory) { + $existing = @(Get-ChildItem -LiteralPath $releaseDirectory -Force) + if ($existing.Count -gt 0) { + throw "$releaseDirectory is not empty. Move it aside before downloading again." + } + } else { + New-Item -ItemType Directory -Path $releaseDirectory -Force | Out-Null + } + + foreach ($assetName in $releaseAssetNames) { + & gh release download $releaseTag ` + --repo $repository ` + --dir $releaseDirectory ` + --pattern $assetName + if ($LASTEXITCODE -ne 0) { + throw "Download failed for $assetName." + } + } + + $isoName = $releaseAssetNames[0] + $checksumName = $releaseAssetNames[1] + $bootReportName = $releaseAssetNames[2] + $isoPath = Join-Path $releaseDirectory $isoName + $checksumPath = Join-Path $releaseDirectory $checksumName + $bootReportPath = Join-Path $releaseDirectory $bootReportName + + $checksumLines = @(Get-Content -LiteralPath $checksumPath | Where-Object { + $_ -match '\s+\*?site-takeover-live-amd64\.iso$' + }) + if ($checksumLines.Count -ne 1) { + throw 'The checksum file does not contain exactly one entry for the ISO.' + } + + $expected = (($checksumLines[0].Trim() -split '\s+')[0]).ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $isoPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($expected -notmatch '^[0-9a-f]{64}$' -or $actual -cne $expected) { + throw "SHA-256 mismatch: expected=$expected actual=$actual" + } + + $isoAsset = @($release.assets | Where-Object name -eq $isoName) + if ($isoAsset.Count -eq 1 -and + -not [string]::IsNullOrWhiteSpace([string]$isoAsset[0].digest) -and + [string]$isoAsset[0].digest -cne "sha256:$actual") { + throw "GitHub asset digest does not match the downloaded ISO: $($isoAsset[0].digest)" + } + + $bootReport = Get-Content -LiteralPath $bootReportPath -Raw + if ($bootReport -notmatch 'BIOS' -or $bootReport -notmatch 'UEFI') { + throw 'The boot report does not contain both BIOS and UEFI entries.' + } + + Write-Check -State OK -Message "Draft prerelease $releaseTag targets $($release.targetCommitish)." + Write-Check -State OK -Message "SHA-256 verified: $actual" + Write-Check -State OK -Message 'BIOS and UEFI boot entries are present.' + Write-Check -State OK -Message "Files are ready under $releaseDirectory" + Write-Check -State WARN -Message 'No USB was formatted or written. Continue with operator\USB.md and the exact physical-disk identity check.' +} + +function Invoke-SelfTest { + $failures = [System.Collections.Generic.List[string]]::new() + + if ($toolSpecs.Count -ne 3) { + $failures.Add('Exactly three supported tools were expected.') + } + if (($toolSpecs.WingetId -ne $null) -notcontains 'Git.Git') { + $failures.Add('Git winget package is missing.') + } + if (($toolSpecs.WingetId -ne $null) -notcontains 'GitHub.cli') { + $failures.Add('GitHub CLI winget package is missing.') + } + if ($releaseAssetNames.Count -ne 3) { + $failures.Add('Exactly three release assets were expected.') + } + foreach ($requiredAsset in @( + 'site-takeover-live-amd64.iso', + 'site-takeover-live-amd64.iso.sha256', + 'site-takeover-live-amd64.boot.txt' + )) { + if ($releaseAssetNames -notcontains $requiredAsset) { + $failures.Add("Release asset is missing: $requiredAsset") + } + } + + $wingetArgs = Get-WingetInstallArguments -PackageId 'Example.Package' + foreach ($required in @('install', '--id', 'Example.Package', '--exact')) { + if ($wingetArgs -notcontains $required) { + $failures.Add("winget arguments do not include $required.") + } + } + + foreach ($requiredCommand in @( + 'gh auth login --hostname github.com --git-protocol https --web', + 'codex login', + "gh repo clone $repository", + "git switch $branch" + )) { + if ($nextCommands -notcontains $requiredCommand) { + $failures.Add("Manual next step is missing: $requiredCommand") + } + } + + if ($failures.Count -gt 0) { + $failures | ForEach-Object { Write-Check -State WARN -Message $_ } + throw "Self-test failed with $($failures.Count) error(s)." + } + + Write-Check -State OK -Message 'Self-test passed. No installer, login, disk, or USB action was run.' +} + +if ($SelfTest) { + Invoke-SelfTest + return +} + +if ($env:OS -ne 'Windows_NT') { + throw 'Run this script in Windows PowerShell or PowerShell on Windows.' +} + +Write-Host 'Site Takeover Lab - company Windows readiness check' +Write-Host 'Default mode is diagnostic only. -DownloadRelease creates verified files but never formats or writes a USB.' + +Write-Section -Title 'Network safety' +Write-Check -State WARN -Message 'Do not use the company LAN or company Wi-Fi. Connect through your personal tether only.' +if ($ConfirmPersonalTether) { + Write-Check -State OK -Message 'Operator explicitly confirmed personal tether use.' +} else { + Write-Check -State INFO -Message 'For installation, rerun with -Install -ConfirmPersonalTether after checking the active network.' +} + +try { + $profiles = @(Get-NetConnectionProfile -ErrorAction Stop) + if ($profiles.Count -eq 0) { + Write-Check -State WARN -Message 'Windows reports no active network profile.' + } else { + foreach ($profile in $profiles) { + Write-Check -State INFO -Message ( + "Active profile: {0} / {1} / IPv4 {2}" -f + $profile.InterfaceAlias, $profile.NetworkCategory, $profile.IPv4Connectivity + ) + } + Write-Check -State WARN -Message 'Windows cannot prove that a profile is personal tethering. Verify its name yourself.' + } +} catch { + Write-Check -State WARN -Message "Could not list network profiles: $($_.Exception.Message)" +} + +Write-Section -Title 'Windows' +try { + $windows = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + Write-Check -State OK -Message "$($windows.Caption) $($windows.Version), PowerShell $($PSVersionTable.PSVersion)" +} catch { + Write-Check -State INFO -Message "$([Environment]::OSVersion.VersionString), PowerShell $($PSVersionTable.PSVersion)" +} + +if (Test-IsAdministrator) { + Write-Check -State INFO -Message 'PowerShell is running as administrator. The readiness check itself does not require this.' +} else { + Write-Check -State OK -Message 'PowerShell is not elevated. winget may request elevation if an installer needs it.' +} + +$winget = Get-Command winget -ErrorAction SilentlyContinue +if ($null -eq $winget) { + Write-Check -State WARN -Message 'winget is missing. Install or update Microsoft App Installer before using -Install.' +} else { + $wingetVersion = (& $winget.Source --version 2>&1 | Select-Object -First 1 | Out-String).Trim() + Write-Check -State OK -Message "winget $wingetVersion" +} + +Write-Section -Title 'Developer tools' +$specsToCheck = if ($DownloadRelease) { + @($toolSpecs | Where-Object Command -eq 'gh') +} else { + $toolSpecs +} +$statuses = @($specsToCheck | ForEach-Object { Get-ToolStatus -Spec $_ }) +foreach ($status in $statuses) { + if ($status.Installed) { + Write-Check -State OK -Message "$($status.Spec.Name): $($status.Version)" + } else { + Write-Check -State WARN -Message "$($status.Spec.Name) is not installed or is not on PATH." + } +} + +Write-Section -Title 'USB visibility (read only)' +try { + $usbDisks = @(Get-Disk -ErrorAction Stop | Where-Object BusType -eq 'USB') + if ($usbDisks.Count -eq 0) { + Write-Check -State INFO -Message 'No USB disk is currently visible.' + } else { + foreach ($disk in $usbDisks) { + $sizeGiB = [math]::Round($disk.Size / 1GB, 1) + Write-Check -State INFO -Message ( + "Disk {0}: {1}, {2} GiB, health {3}, operational {4}" -f + $disk.Number, $disk.FriendlyName, $sizeGiB, $disk.HealthStatus, + ($disk.OperationalStatus -join ',') + ) + } + Write-Check -State WARN -Message 'This script only reports USB disks. It never repairs, formats, erases, or writes them.' + } +} catch { + Write-Check -State WARN -Message "Could not list USB disks: $($_.Exception.Message)" +} + +if ($Install) { + Write-Section -Title 'Explicit installation' + if (-not $ConfirmPersonalTether) { + throw 'Installation was blocked. Confirm a personal tether, then pass -ConfirmPersonalTether with -Install.' + } + if ($null -eq $winget) { + throw 'Installation was blocked because winget is unavailable.' + } + + foreach ($status in $statuses) { + if ($status.Installed) { + Write-Check -State INFO -Message "$($status.Spec.Name) is already present; skipped." + } elseif ($status.Spec.Command -eq 'codex') { + Install-CodexCli + } else { + Install-WingetTool -Spec $status.Spec + } + } + Write-Check -State OK -Message 'Requested installers finished. Open a new PowerShell before the next commands.' +} + +if ($DownloadRelease) { + Download-VerifiedRelease + return +} + +Write-Section -Title 'Next manual commands' +Write-Host 'Authentication is intentionally manual. The script never receives or saves a token.' +foreach ($command in $nextCommands) { + Write-Host $command +} +Write-Host '' +Write-Host 'After cloning, follow operator/COMPANY-SETUP.md. Do not write the USB until a verified ISO and SHA-256 exist.' diff --git a/labs/site-takeover/package.json b/labs/site-takeover/package.json new file mode 100644 index 0000000..f092f9e --- /dev/null +++ b/labs/site-takeover/package.json @@ -0,0 +1,9 @@ +{ + "name": "site-takeover-lab", + "private": true, + "type": "module", + "scripts": { + "test": "node --test tests/*.test.mjs", + "check": "node --test tests/*.test.mjs" + } +} diff --git a/labs/site-takeover/qa/screenshots/firefox-guide-success-final.png b/labs/site-takeover/qa/screenshots/firefox-guide-success-final.png new file mode 100644 index 0000000..04d046b Binary files /dev/null and b/labs/site-takeover/qa/screenshots/firefox-guide-success-final.png differ diff --git a/labs/site-takeover/qa/screenshots/firefox-site-360-final.png b/labs/site-takeover/qa/screenshots/firefox-site-360-final.png new file mode 100644 index 0000000..fee0f96 Binary files /dev/null and b/labs/site-takeover/qa/screenshots/firefox-site-360-final.png differ diff --git a/labs/site-takeover/qa/screenshots/guide-360.png b/labs/site-takeover/qa/screenshots/guide-360.png new file mode 100644 index 0000000..3fd3aac Binary files /dev/null and b/labs/site-takeover/qa/screenshots/guide-360.png differ diff --git a/labs/site-takeover/qa/screenshots/guide-desktop.png b/labs/site-takeover/qa/screenshots/guide-desktop.png new file mode 100644 index 0000000..3c8c596 Binary files /dev/null and b/labs/site-takeover/qa/screenshots/guide-desktop.png differ diff --git a/labs/site-takeover/qa/screenshots/guide-success.png b/labs/site-takeover/qa/screenshots/guide-success.png new file mode 100644 index 0000000..8067804 Binary files /dev/null and b/labs/site-takeover/qa/screenshots/guide-success.png differ diff --git a/labs/site-takeover/qa/screenshots/site-360.png b/labs/site-takeover/qa/screenshots/site-360.png new file mode 100644 index 0000000..8b9df6b Binary files /dev/null and b/labs/site-takeover/qa/screenshots/site-360.png differ diff --git a/labs/site-takeover/qa/screenshots/site-defaced.png b/labs/site-takeover/qa/screenshots/site-defaced.png new file mode 100644 index 0000000..85875da Binary files /dev/null and b/labs/site-takeover/qa/screenshots/site-defaced.png differ diff --git a/labs/site-takeover/qa/screenshots/site-desktop.png b/labs/site-takeover/qa/screenshots/site-desktop.png new file mode 100644 index 0000000..9594ddb Binary files /dev/null and b/labs/site-takeover/qa/screenshots/site-desktop.png differ diff --git a/labs/site-takeover/qa/screenshots/target-disk-blocked-final.png b/labs/site-takeover/qa/screenshots/target-disk-blocked-final.png new file mode 100644 index 0000000..fea9de8 Binary files /dev/null and b/labs/site-takeover/qa/screenshots/target-disk-blocked-final.png differ diff --git a/labs/site-takeover/qa/screenshots/target-disk-hotplug-block-final.png b/labs/site-takeover/qa/screenshots/target-disk-hotplug-block-final.png new file mode 100644 index 0000000..368e2eb Binary files /dev/null and b/labs/site-takeover/qa/screenshots/target-disk-hotplug-block-final.png differ diff --git a/labs/site-takeover/qa/screenshots/target-exercise-return-final.png b/labs/site-takeover/qa/screenshots/target-exercise-return-final.png new file mode 100644 index 0000000..e5edd4b Binary files /dev/null and b/labs/site-takeover/qa/screenshots/target-exercise-return-final.png differ diff --git a/labs/site-takeover/qa/screenshots/target-maintenance-codex-final.png b/labs/site-takeover/qa/screenshots/target-maintenance-codex-final.png new file mode 100644 index 0000000..d41f22c Binary files /dev/null and b/labs/site-takeover/qa/screenshots/target-maintenance-codex-final.png differ diff --git a/labs/site-takeover/qa/screenshots/target-ready-final.png b/labs/site-takeover/qa/screenshots/target-ready-final.png new file mode 100644 index 0000000..55319b2 Binary files /dev/null and b/labs/site-takeover/qa/screenshots/target-ready-final.png differ diff --git a/labs/site-takeover/target/site/assets/site.css b/labs/site-takeover/target/site/assets/site.css new file mode 100644 index 0000000..ea42866 --- /dev/null +++ b/labs/site-takeover/target/site/assets/site.css @@ -0,0 +1,380 @@ +:root { + color-scheme: light; + --paper: #f2ede3; + --paper-deep: #e4dacb; + --ink: #211d19; + --muted: #71675d; + --accent: #8c2f20; + --line: rgba(33, 29, 25, 0.18); + --serif: "Yu Mincho", "Hiragino Mincho ProN", "Noto Serif JP", serif; + --sans: "Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: var(--sans); + line-height: 1.75; +} + +a { + color: inherit; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 2rem; + max-width: 1180px; + margin: 0 auto; + padding: 1.25rem 2rem; + border-bottom: 1px solid var(--line); +} + +.brand { + display: flex; + align-items: center; + gap: 0.85rem; + text-decoration: none; +} + +.brand-mark { + display: grid; + width: 3rem; + height: 3rem; + place-items: center; +} + +.brand-mark svg { + width: 100%; + fill: none; + stroke: var(--accent); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2.6; +} + +.brand strong, +.brand small { + display: block; +} + +.brand strong { + font-family: var(--serif); + font-size: 1.15rem; + letter-spacing: 0.08em; +} + +.brand small { + color: var(--muted); + font-size: 0.7rem; +} + +nav { + display: flex; + gap: 1.5rem; + font-size: 0.82rem; +} + +nav a { + text-decoration-thickness: 1px; + text-underline-offset: 0.35rem; +} + +main { + max-width: 1180px; + margin: 0 auto; + padding: 0 2rem 5rem; +} + +.hero { + display: grid; + min-height: 590px; + grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr); + align-items: center; + border-bottom: 1px solid var(--line); +} + +.hero-copy { + padding: 4rem 3rem 4rem 0; +} + +.eyebrow { + margin: 0 0 1rem; + color: var(--accent); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.17em; + text-transform: uppercase; +} + +h1, +h2 { + margin: 0; + font-family: var(--serif); + font-weight: 500; + letter-spacing: 0.04em; + line-height: 1.35; +} + +h1 { + max-width: 8em; + font-size: clamp(3rem, 6vw, 5.4rem); +} + +h2 { + font-size: clamp(2rem, 4vw, 3.5rem); +} + +.hero-copy > p:not(.eyebrow) { + max-width: 32rem; + margin: 2rem 0; + color: var(--muted); +} + +.hero-link { + display: inline-flex; + align-items: center; + min-height: 3rem; + padding: 0 1.25rem; + border: 1px solid var(--ink); + text-decoration: none; +} + +.hero-link::after { + margin-left: 1.5rem; + content: "→"; +} + +.hero-art { + position: relative; + display: grid; + min-height: 470px; + place-items: center; + overflow: hidden; + background: + linear-gradient(135deg, rgba(140, 47, 32, 0.05), transparent 55%), + var(--paper-deep); +} + +.cup { + position: relative; + z-index: 2; + width: 210px; + height: 155px; + border: 3px solid var(--ink); + border-radius: 8px 8px 72px 72px; + background: #f8f2e8; +} + +.coffee { + position: absolute; + top: 9px; + right: 12px; + left: 12px; + height: 22px; + border-radius: 50%; + background: #553322; +} + +.handle { + position: absolute; + top: 30px; + right: -62px; + width: 68px; + height: 82px; + border: 3px solid var(--ink); + border-left: 0; + border-radius: 0 42px 42px 0; +} + +.saucer { + position: absolute; + z-index: 1; + top: calc(50% + 85px); + width: 300px; + height: 36px; + border: 3px solid var(--ink); + border-radius: 50%; +} + +.steam { + position: absolute; + top: 72px; + width: 44px; + height: 120px; + border-left: 3px solid var(--accent); + border-radius: 50%; +} + +.steam-one { + left: calc(50% - 35px); + transform: rotate(8deg); +} + +.steam-two { + left: calc(50% + 30px); + top: 90px; + height: 96px; + transform: rotate(-8deg); +} + +.notice, +.split-section, +.menu-section, +.access-section { + display: grid; + padding: 4.5rem 0; + border-bottom: 1px solid var(--line); +} + +.notice { + grid-template-columns: 80px minmax(0, 1fr); +} + +.notice > div { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + align-items: baseline; + gap: 2rem; +} + +.notice-message { + margin: 0; + font-family: var(--serif); + font-size: clamp(1.6rem, 3vw, 2.7rem); + line-height: 1.5; +} + +.section-number { + margin: 0; + color: var(--muted); + font-family: ui-monospace, monospace; + font-size: 0.8rem; +} + +.split-section { + grid-template-columns: 80px 1.3fr 0.7fr; + gap: 2rem; +} + +.split-section > p:last-child { + align-self: end; + margin: 0; + color: var(--muted); +} + +.menu-section { + grid-template-columns: 80px 1fr; +} + +.menu-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 2rem; +} + +.menu-list { + grid-column: 2; + margin: 3rem 0 0; + padding: 0; + list-style: none; +} + +.menu-list li { + display: flex; + justify-content: space-between; + gap: 2rem; + padding: 1.2rem 0; + border-top: 1px solid var(--line); +} + +.menu-list strong { + color: var(--accent); + font-family: ui-monospace, monospace; +} + +.access-section { + grid-template-columns: 80px 1fr 1fr; + gap: 2rem; +} + +.access-section p { + margin: 0.25rem 0; +} + +.hours { + font-family: var(--serif); + font-size: 2rem; +} + +footer { + display: flex; + justify-content: space-between; + gap: 2rem; + max-width: 1180px; + margin: 0 auto; + padding: 2rem; + color: var(--muted); + font-size: 0.72rem; +} + +@media (max-width: 780px) { + .site-header { + align-items: flex-start; + } + + nav { + display: none; + } + + main { + padding-inline: 1.25rem; + } + + .hero { + min-height: auto; + grid-template-columns: 1fr; + } + + .hero-copy { + padding: 4rem 0 3rem; + } + + .hero-art { + min-height: 360px; + } + + .notice, + .split-section, + .menu-section, + .access-section { + grid-template-columns: 1fr; + gap: 1.5rem; + padding: 3rem 0; + } + + .notice > div { + grid-template-columns: 1fr; + gap: 0; + } + + .menu-list { + grid-column: 1; + } + + footer { + flex-direction: column; + } +} diff --git a/labs/site-takeover/target/site/assets/staff.css b/labs/site-takeover/target/site/assets/staff.css new file mode 100644 index 0000000..33d949a --- /dev/null +++ b/labs/site-takeover/target/site/assets/staff.css @@ -0,0 +1,180 @@ +:root { + color-scheme: light; + --bg: #eef0f2; + --panel: #ffffff; + --ink: #17202a; + --muted: #5d6874; + --accent: #185d72; + --line: #cdd4da; +} + +* { + box-sizing: border-box; +} + +body { + min-height: 100vh; + margin: 0; + padding: 4rem 1.25rem; + background: var(--bg); + color: var(--ink); + font-family: "Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif; + line-height: 1.7; +} + +.staff-shell { + max-width: 780px; + margin: 0 auto; + padding: clamp(1.5rem, 5vw, 3.5rem); + border: 1px solid var(--line); + background: var(--panel); + box-shadow: 0 16px 50px rgba(23, 32, 42, 0.08); +} + +.staff-label { + margin: 0 0 0.5rem; + color: var(--accent); + font-family: ui-monospace, "Cascadia Mono", monospace; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.18em; +} + +h1 { + margin: 0; + font-size: clamp(1.8rem, 4vw, 2.7rem); +} + +h2 { + margin: 0 0 0.75rem; + font-size: 1rem; +} + +.tool-list { + margin: 2rem 0; + padding: 0; + border-top: 1px solid var(--line); + list-style: none; +} + +.tool-list li { + border-bottom: 1px solid var(--line); +} + +.tool-list a, +.tool-list .disabled { + display: block; + padding: 1.25rem 0; +} + +.tool-list a { + color: inherit; + text-decoration: none; +} + +.tool-list a:hover span, +.tool-list a:focus-visible span { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 0.25rem; +} + +.tool-list span, +.tool-list small { + display: block; +} + +.tool-list span { + font-weight: 700; +} + +.tool-list small, +.staff-footnote { + color: var(--muted); +} + +.tool-list .disabled { + opacity: 0.55; +} + +.staff-footnote { + font-size: 0.78rem; +} + +.back-link { + display: inline-block; + margin-bottom: 2rem; + color: var(--accent); + text-underline-offset: 0.25rem; +} + +form { + margin-top: 2rem; +} + +label { + display: block; + margin-bottom: 0.5rem; + font-weight: 700; +} + +.field-row { + display: flex; + gap: 0.75rem; +} + +input, +button { + min-height: 3rem; + border: 1px solid var(--line); + border-radius: 3px; + font: inherit; +} + +input { + min-width: 0; + flex: 1; + padding: 0 0.9rem; +} + +button { + padding: 0 1.2rem; + border-color: var(--accent); + background: var(--accent); + color: white; + cursor: pointer; + font-weight: 700; +} + +.result { + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid var(--line); +} + +pre { + overflow-x: auto; + margin: 0; + padding: 1rem; + background: #17202a; + color: #eef7fa; + font: 0.88rem/1.6 ui-monospace, "Cascadia Mono", monospace; + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 560px) { + body { + padding: 0; + } + + .staff-shell { + min-height: 100vh; + border: 0; + } + + .field-row { + align-items: stretch; + flex-direction: column; + } +} diff --git a/labs/site-takeover/target/site/data/notice.txt b/labs/site-takeover/target/site/data/notice.txt new file mode 100644 index 0000000..55a2fcb --- /dev/null +++ b/labs/site-takeover/target/site/data/notice.txt @@ -0,0 +1 @@ +本日は通常どおり営業しています。 diff --git a/labs/site-takeover/target/site/inc/site.php b/labs/site-takeover/target/site/inc/site.php new file mode 100644 index 0000000..af906de --- /dev/null +++ b/labs/site-takeover/target/site/inc/site.php @@ -0,0 +1,30 @@ + + + + + + + + 喫茶みちくさ + + + + + +
+
+
+

COFFEE & SMALL BREAK

+

少しだけ、
みちくさしませんか。

+

自家焙煎のコーヒーと、毎朝焼くトースト。仕事の合間にも、散歩の途中にも。

+ 今日のメニューを見る +
+ +
+ +
+

01

+
+

本日のお知らせ

+

+
+
+ +
+

02

+
+

お店について

+

町のすき間にある、
十二席の喫茶店です。

+
+

急いで飲む日も、ぼんやり過ごす日も歓迎します。コーヒー豆は毎週少量ずつ焙煎し、香りが残るうちに使い切ります。

+
+ + + +
+

04

+
+

営業時間

+

10:00—19:00

+

水曜定休

+
+
+

アクセス

+

駅の南口から徒歩6分
小さな赤い看板が目印です

+
+
+
+ +
+ © 喫茶みちくさ + このサイトは演習用の架空サイトです +
+ + diff --git a/labs/site-takeover/target/site/robots.txt b/labs/site-takeover/target/site/robots.txt new file mode 100644 index 0000000..89ce87c --- /dev/null +++ b/labs/site-takeover/target/site/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: /staff/ diff --git a/labs/site-takeover/target/site/staff/index.php b/labs/site-takeover/target/site/staff/index.php new file mode 100644 index 0000000..0cab05b --- /dev/null +++ b/labs/site-takeover/target/site/staff/index.php @@ -0,0 +1,29 @@ + + + + + + スタッフ用ツール + + + +
+

STAFF ONLY

+

店舗サイト 管理補助ツール

+

サイトの状態確認に使う社内向けページです。

+ +

認証システムの移行作業中につき、一時的に社内ネットワークからのみ利用できます。

+
+ + diff --git a/labs/site-takeover/target/site/staff/network-check.php b/labs/site-takeover/target/site/staff/network-check.php new file mode 100644 index 0000000..4486d21 --- /dev/null +++ b/labs/site-takeover/target/site/staff/network-check.php @@ -0,0 +1,56 @@ +&1'; + $output = (string) shell_exec($command); + } +} + +function h(string $value): string +{ + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} +?> + + + + + + 接続先の確認 | スタッフ用ツール + + + +
+ ← ツール一覧へ +

NETWORK CHECK

+

接続先の確認

+

接続先のIPアドレスまたはホスト名を入力してください。

+ +
+ +
+ + +
+
+ + +
+

確認結果

+
+
+ +
+ + diff --git a/labs/site-takeover/tests/dev-server.sh b/labs/site-takeover/tests/dev-server.sh new file mode 100755 index 0000000..f98fc8e --- /dev/null +++ b/labs/site-takeover/tests/dev-server.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +readonly source_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +readonly runtime_root="/tmp/site-takeover-dev" +readonly server_pattern="php -S 0.0.0.0:8137 -t ${runtime_root}/public" + +stop_server() { + pkill -f "${server_pattern}" >/dev/null 2>&1 || true +} + +start_server() { + [[ "${runtime_root}" == "/tmp/site-takeover-dev" ]] || exit 1 + stop_server + rm -rf -- "${runtime_root}" + install -d -m 0755 "${runtime_root}/public" "${runtime_root}/guide" "${runtime_root}/data" + cp -a "${source_root}/target/site/." "${runtime_root}/public/" + cp -a "${source_root}/guide/." "${runtime_root}/guide/" + ln -s ../guide "${runtime_root}/public/start" + sed -i 's#http://10\.13\.37\.10#http://127.0.0.1:8137#g' "${runtime_root}/guide/index.html" + + printf '%s\n' '本日は通常どおり営業しています。' > "${runtime_root}/data/announcement.txt" + chown -R root:root "${runtime_root}/public" "${runtime_root}/guide" + chown root:www-data "${runtime_root}/data" "${runtime_root}/data/announcement.txt" + chmod 0775 "${runtime_root}/data" + chmod 0664 "${runtime_root}/data/announcement.txt" + + nohup runuser -u www-data -- \ + env SITE_NOTICE_PATH="${runtime_root}/data/announcement.txt" \ + php -S 0.0.0.0:8137 -t "${runtime_root}/public" \ + >"${runtime_root}/server.log" 2>&1 & + printf '%s\n' "$!" > "${runtime_root}/server.pid" + + for _ in {1..30}; do + if curl --fail --silent --max-time 1 http://127.0.0.1:8137/ >/dev/null; then + printf 'Dev server ready at http://127.0.0.1:8137/\n' + return + fi + sleep 0.2 + done + + cat "${runtime_root}/server.log" >&2 + exit 1 +} + +case "${1:-}" in + start) + start_server + ;; + stop) + stop_server + ;; + *) + printf 'Usage: %s {start|stop}\n' "$0" >&2 + exit 1 + ;; +esac diff --git a/labs/site-takeover/tests/guide-model.test.mjs b/labs/site-takeover/tests/guide-model.test.mjs new file mode 100644 index 0000000..318f994 --- /dev/null +++ b/labs/site-takeover/tests/guide-model.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildDefacePayload, + classifyCommandOutput, + classifyDiscoveredPath, + classifyNoticeOutput, + classifyScanOutput, + classifySiteTreeOutput, + classifyWorkdirOutput, + homepageShowsTeam, + normalizeTeamName, + scoreDebrief, +} from "../guide/guide-model.mjs"; + +test("nmap output recognizes the HTTP entry without exact full-output matching", () => { + const result = classifyScanOutput(` +Nmap scan report for 10.13.37.10 +PORT STATE SERVICE VERSION +80/tcp open http Apache httpd 2.4.66 +`); + + assert.equal(result.ok, true); +}); + +test("nmap connectivity failure returns a recoverable diagnostic", () => { + const result = classifyScanOutput("Note: Host seems down."); + + assert.equal(result.ok, false); + assert.match(result.message, /LANケーブル/); +}); + +test("robots path accepts a missing trailing slash", () => { + assert.equal(classifyDiscoveredPath("/staff").ok, true); + assert.equal(classifyDiscoveredPath("/STAFF/").ok, true); + assert.equal(classifyDiscoveredPath("/admin/").ok, false); +}); + +test("command execution requires both the correct intent and www-data evidence", () => { + assert.equal(classifyCommandOutput("www-data", "identity").ok, true); + assert.equal(classifyCommandOutput("uid=33(www-data) gid=33(www-data)", "identity").ok, true); + assert.equal(classifyCommandOutput("www-data", "password").ok, false); +}); + +test("filesystem observations are recognized by evidence, not prompt formatting", () => { + assert.equal(classifyWorkdirOutput("PING...\n/srv/shop-site/public/staff\n").ok, true); + assert.equal(classifySiteTreeOutput("drwxr-xr-x data\ndrwxr-xr-x public\n").ok, true); + assert.equal(classifyNoticeOutput("本日は通常どおり営業しています。\n").ok, true); +}); + +test("team name rejects shell control characters", () => { + assert.equal(normalizeTeamName("TEAM AO").ok, true); + assert.equal(normalizeTeamName("青チーム_1").ok, true); + assert.equal(normalizeTeamName("TEAM; id").ok, false); + assert.equal(normalizeTeamName("$(id)").ok, false); +}); + +test("deface payload writes only the discovered announcement file", () => { + const result = buildDefacePayload("TEAM AO"); + + assert.equal(result.ok, true); + assert.match(result.payload, /^127\.0\.0\.1; printf/); + assert.match(result.payload, /> \.\.\/\.\.\/data\/announcement\.txt$/); + assert.doesNotMatch(result.payload, /sudo|\/root/); +}); + +test("homepage check verifies the actual team-specific state", () => { + const html = "

SECURITY TEST SUCCESS: TEAM AO

"; + + assert.equal(homepageShowsTeam(html, "TEAM AO"), true); + assert.equal(homepageShowsTeam(html, "TEAM B"), false); +}); + +test("debrief passes only when all three causal links are correct", () => { + assert.equal(scoreDebrief({ + where: "target", + privilege: "limited", + reason: "file", + }).ok, true); + + assert.equal(scoreDebrief({ + where: "kali", + privilege: "limited", + reason: "file", + }).ok, false); +}); diff --git a/labs/site-takeover/tests/static-contract.test.mjs b/labs/site-takeover/tests/static-contract.test.mjs new file mode 100644 index 0000000..11fb9da --- /dev/null +++ b/labs/site-takeover/tests/static-contract.test.mjs @@ -0,0 +1,246 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const root = new URL("../", import.meta.url); + +async function text(path) { + return readFile(new URL(path, root), "utf8"); +} + +test("target page remains an ordinary site without CTF vocabulary", async () => { + const page = await text("target/site/index.php"); + + assert.doesNotMatch(page, /CTF|RCE|command injection|コマンドインジェクション/i); + assert.match(page, /本日のお知らせ/); +}); + +test("target and guide have no external asset dependencies", async () => { + const files = await Promise.all([ + text("target/site/index.php"), + text("target/site/staff/index.php"), + text("target/site/staff/network-check.php"), + text("guide/index.html"), + ]); + const externalAsset = /<(?:script|link|img)[^>]+(?:src|href)=["']https?:\/\/(?!10\.13\.37\.10)/i; + + for (const file of files) { + assert.doesNotMatch(file, externalAsset); + } +}); + +test("the deliberate shell boundary is visibly marked for operators", async () => { + const diagnostic = await text("target/site/staff/network-check.php"); + + assert.match(diagnostic, /Intentionally vulnerable/); + assert.match(diagnostic, /shell_exec\(\$command\)/); +}); + +test("participant guide verifies real homepage state instead of paste-only success", async () => { + const guide = await text("guide/guide.mjs"); + + assert.match(guide, /fetch\(`\/\?lab-check=/); + assert.match(guide, /homepageShowsTeam/); +}); + +test("commands say whether they belong in Kali or the web form", async () => { + const page = await text("guide/index.html"); + + assert.match(page, /操作場所: Kaliのターミナル/); + assert.match(page, /入力場所: Webの「接続先」欄/); + assert.match(page, /id="whoami-experiment" hidden/); + assert.match(page, /id="workdir-experiment" hidden/); + assert.match(page, /