My First Vulnbox (1) 一臺多階段攻擊靶機的架構與設計
系列文章目錄
第一篇:My First Vulnbox (1) 一臺多階段攻擊靶機的架構與設計
第二篇:My First Vulnbox (2) 靶機的 7 層漏洞鏈解析:撰寫中
第三篇:My First Vulnbox (3) 意猶未盡:撰寫中
前言
在今年(2026年)的 AIS3 Junior 中,我和組員自製了一台包含七階段攻擊鏈的靶機,我也從中學習到了許多,無論是網站架構或是漏洞利用。也多虧助教的建議和 AI 的幫忙,我們才能在短時間內生出這台靶機的程式碼和 exploit,並順利在報告上 live demo。這篇文章將會介紹靶機的架構與設計,並分享一些我在製作靶機時的心得。
- GitHub Repo: r3xdj/ais3-junior-vulnlab: AIS3 Junior 2026 Project: A custom vulnerable machine designed for CTF/boot2root learning.
- 簡報說明:AIS3 Junior-第九組_靶機製作
建議同時開著程式碼與文章一起看
發想
以下想法誕生的時間非線性,而是以類別分類。畢竟我們也是邊做邊滾動式調整
主要設計邏輯
發想階段,助教建議可以有兩台機器:一台是對外網站,一台是內部服務,用對外網站攻擊內部服務。同時可以更進一步設計成先用 SSRF 拿下內部伺服器,再回頭打爆外面這台機器,增加攻擊的複雜性。此外,我們也考慮在其中埋入一個 Apache CVE (即使後來沒有在攻擊鏈中使用)。
靶機主題
製作靶機總得有個故事情境,一開始的確是在想可以弄成像真實企業的網站,一開始有組員提了做銀行網站,不過最後我們選用了「AIS3 榮譽考試」作為主題。

對外網站
網站漏洞設計
對外網站那台機器很直覺的可以設計成一個註冊 / 登入頁面,玩家需要進行繞過,取得管理員權限後進入 Dashboard,而 Dashboard 中有個功能有 SSRF 的漏洞。助教表示這也很符合實際場景,因為實務上的確可能因為是內部網站而資安防護較薄弱。
管理登入狀態我馬上就想到了 CTF 常客 JWT。我們曾考慮過可以設計成:
- 允許加密演算法為 none
- 弱 / 預設 Secret
不過最終我們選用了 JSON Injection,靈感源於我在 CryptoHack 打過的題目:JSON in JSON。
網站框架
我只知道 PHP 和 Python Flask,我又比較懂 Python 的語法所以放棄了世界上最好的程式語言選擇了 Python Flask。(雖然也是 AI 在寫網站XD 但 AI 寫完我也得稍微檢查一下對吧)
啊我不會前後端所以也沒用 Fast API,反正就全 Flask 實現XD
途中助教說用 Go 寫能讓玩家玩逆向,不過為時已晚而且 My 不會 Go。
Apache
不是說過我們有考慮塞 Apache 的 CVE 嗎?但我們用 Python Flask ㄋㄟˉ結果就導致殺雞用牛刀,後來 Apache 只被作為 proxy 使用:按照網址把請求分配到給前端 server 或後端 server。
早知道去學前後端
程式碼洩漏
那個 JSON Injection 需要玩家看到原始碼才知道怎麼注入(總不可能讓玩家通靈吧 XD),於是我們決定在靶機中加入一個能造成原始碼洩漏的漏洞。我們起初有以下兩種想法
Apache CVE-2021-41773
一位組員翻到了這個古老的 CVE 能 Path Traversal,雖然我們也是搞了很久才成功。但我們的架構上 Apache、frontend 和 API 分屬於三個 container,所以 Apache 就只能讀到他自己 container 裡的檔案,無法讀到後端程式碼,我們只好另闢他法。助教說如果要這樣搞那可能用 php 那種的架構比較適合。
.git 洩漏
這裡會遇到類似的問題是 .git 是放在前端的,沒道理前端的 .git 裡會有後端程式碼吧(畢竟我們的架構上 frontend 和 api 是分兩個 container,總不可能是把全部資料包括 Dockerfile 都塞進去吧 XD 這樣感覺跟真實世界就差太多了)
ImageMagick CVE
苦於前兩種方法都不適合,但洩露程式碼又是不可或缺的步驟,這便成為了我們唯一一次線上 meeting(那時是半夜w)的主題之一。(而且我們蠻晚才討論的 XD 別組都在好幾天前就有 meeting 過了)絞盡腦汁我想起之前的打 boroCTF 時 shrimp2845 講過 ImageMagick 有一個 LFI 的 CVE:CVE-2022-44268

想說或許可以可以拿來試試,比如制作一個頭像上傳功能,後端會用 ImageMagick 處裡。
教材中心 Path Traversal
這是我們最後決定的方案。因為還在研究 ImageMagick 怎麽搞時就發現做這個好像更容易w
我們決定加入一個「教材中心」功能,其中有 Path traversal 漏洞,如此便可 Dump 下後端程式碼。
內網服務
我不知道 SSRF 要去打誰,於是跑去問 AI。他說可以用 SSRF 送 Gopher 封包去打 Redis。
我一看 ㄟ這不幾乎就是 whale120 出的那題 Web:Image Viewer 嗎?(那題也是 Gopher 不過不是打 Redis)真是太好了!
詳情見:AIS3 Junior Writeup | R3X’s Blog # [Web] Image Viewer
而 Redis 後面有個 Celery Worker,我們設計的業務情境是網站有個「證書生成」功能,前端送出生成證書請求後會先建立一個 Task 放進 Redis 佇列,Celery Worker 再把他拿出來執行。其中可以讓 Celery Worker 接受 pickle 格式,這樣就會有 RCE 的洞。
接著我們需要把「用 pickle」這件事情合理化,畢竟我們的目標是模擬真實企業,所以一定是有某種需求,且此需求用 pickle 實現更方便(即使不安全)才會使用 pickle。當時我和 AI 討論的結果是「讓證書會產生能力雷達圖,這樣就會需要傳入 Matplotlib Figure 物件,從而需要使用 pickle」。但最近回看發現根本不是這麼回事,因為 Matplotlib Figure 物件很大,不太可能是在 Celery Worker 外製圖再傳進去,且當時 AI 生出來的 code 也是在 Celery Worker 處裡 Task 時才生成雷達圖。畢竟那時也快交件截止了所以就來不及 code review 到。
靶機架構統整
先說一下,web-gateway 這個 container 其實就是我們的後端。會叫這名字是因為一開始 AI 生架構有這個,然後一開始懞懞懂懂之時我們把它做成後端了w 所以算是一種歷史遺留(?
實作
Docker Compose 與 run.sh
先說這個,因為這涉及到之後一些東西的設計邏輯。由於我們希望每次 DB 密碼、JWT_SECRET_KEY 和 Apache 的 SSH 密碼是動態產生的,所以我們建立了 run.sh 讓玩家用他來開 Docker,而這個腳本做的事情就是生成隨機密碼們。我們希望動態密碼的原因之一是讓玩家沒辦法作弊,二是讓重開 Docker 之後先前的 JWT 會失效(不然瀏覽器 cookie 還存著,一打開網站會直接已登入)
run.sh#!/bin/sh set -e export DB_PASSWORD="$(openssl rand -hex 24)" export JWT_SECRET_KEY="$(openssl rand -hex 32)" export SSH_PIVOT_PASSWORD="$(openssl rand -hex 24)" docker compose down -v docker compose up "$@"
需要 docker compose down -v 是因為這樣他才會把 SQL 資料庫清掉然後重新產生。
我們也在在此先放上 docker-compose.yml,讓讀者先清楚靶機的整體架構
docker-compose.ymlservices: apache: build: context: ./ingress container_name: ctf-apache environment: SSH_PIVOT_PASSWORD: ${SSH_PIVOT_PASSWORD} ports: - "8080:80" - "2222:22" networks: - public_net - internal_net depends_on: - web-gateway - frontend volumes: - type: bind source: ./flags/final_flag.txt target: /root/final_flag.txt read_only: true - type: bind source: ./flags/apache_flag.txt target: /flag.txt read_only: false postgres: build: ./datastores/postgres environment: POSTGRES_DB: ${DB_NAME} POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - ./datastores/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro - pgdata:/var/lib/postgresql/data networks: - internal_net healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] interval: 5s timeout: 3s retries: 5 redis: image: redis:7-alpine networks: - internal_net web-gateway: build: ./web-gateway environment: DB_HOST: postgres DB_PORT: 5432 DB_NAME: ${DB_NAME} DB_USER: ${DB_USER} DB_PASSWORD: ${DB_PASSWORD} JWT_SECRET_KEY: ${JWT_SECRET_KEY} REDIS_HOST: redis REDIS_PORT: 6379 CERTIFICATE_DIR: /certificates FLAG_STAGE2: ${FLAG_STAGE2} FLAG_STAGE3: ${FLAG_STAGE3} volumes: - certificates:/certificates depends_on: postgres: condition: service_healthy networks: - internal_net - public_net frontend: build: ./frontend environment: JWT_SECRET_KEY: ${JWT_SECRET_KEY} networks: - public_net depends_on: - web-gateway celery-worker: build: ./celery-worker restart: unless-stopped command: celery -A celery_app worker --loglevel=INFO environment: REDIS_HOST: redis REDIS_PORT: 6379 DB_HOST: postgres DB_PORT: 5432 DB_NAME: ${DB_NAME} DB_USER: ${DB_USER} DB_PASSWORD: ${DB_PASSWORD} SSH_PIVOT_PASSWORD: ${SSH_PIVOT_PASSWORD} CERTIFICATE_DIR: /certificates depends_on: - redis - postgres volumes: - certificates:/certificates - type: bind source: ./flags/user_flag.txt target: /flags/user_flag.txt read_only: true - type: bind source: ./flags/root_flag.txt target: /root/.root_flag_seed read_only: true networks: - internal_net networks: public_net: driver: bridge internal_net: internal: true volumes: pgdata: certificates:
其中 .env
DB_NAME=app_db
DB_USER=app_user
# DB_PASSWORD=ais3-dev-db-password
# JWT_SECRET_KEY=ais3-dev-jwt-secret-change-this
REDIS_HOST=redis
REDIS_PORT=6379
FLAG_STAGE2=AIS3{JwT_15_uNbRE4k4BL3(ᗒᗣᗕ)}
FLAG_STAGE3=AIS3{R3Ad_THE_SOuRce_COde_NeXT_7!Me_1o1}
# SSH_PIVOT_PASSWORD=AIS3-ops-2026
Flag 放置邏輯
一樣是先講在前面幫助理解,即使當時做靶機這是做後步驟
從上面可以看到我們並沒有將整個 flags/ 掛載進去,這是為了避免跳關。
而為何 Flag2 和 Flag3 要放環境變數呢?因為這兩個 Flag 分別要放在後端回傳的 JSON、Redis 的 key,所以直接從環境變數拿比讀檔案方便。
而我們總共有 7 個 Flag,對應到攻擊鏈的 7 個步驟。
Apache Ingress
主配置 httpd.conf 如下:
Listen 80
ServerName localhost
User daemon
Group daemon
LoadModule mpm_event_module modules/mod_mpm_event.so
LoadModule authn_core_module modules/mod_authn_core.so
LoadModule authz_core_module modules/mod_authz_core.so
LoadModule authz_host_module modules/mod_authz_host.so
LoadModule authz_user_module modules/mod_authz_user.so
LoadModule dir_module modules/mod_dir.so
LoadModule mime_module modules/mod_mime.so
LoadModule alias_module modules/mod_alias.so
LoadModule cgid_module modules/mod_cgid.so
LoadModule log_config_module modules/mod_log_config.so
LoadModule unixd_module modules/mod_unixd.so
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http_module modules/mod_proxy_http.so
Mutex posixsem default
<Directory />
Require all granted
</Directory>
DocumentRoot "/usr/local/apache2/htdocs"
ErrorLog /proc/self/fd/2
CustomLog /proc/self/fd/1 combined
IncludeOptional conf/conf.d/*.conf
值得注意的是
LoadModule cgid_module modules/mod_cgid.so
這句開啟 CGI 模組,這是 CVE-CVE-2021-41773 要成立的必要條件。
以及
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http_module modules/mod_proxy_http.so
因為我們把 Apache 當 proxy 來用,所以要引入這兩個模組。
接著是
<Directory />
Require all granted
</Directory>
把權限放的很寬,因為我之前設成 Require all denied 時 Path Traversal 失敗所以乾脆直接 all granted。
proxy.conf
嗯。Proxy 設置。為了讓 CGI 合理的被啟用,我們真的做了兩個 cgi-bin,就是一些 debug 而已w
不過玩家如果想用那就要先用通靈技巧猜出 cgi-bin 的名字XD 不過也沒差因為這與解題無關。
ProxyPreserveHost On
# CVE 測試路徑: 不要交給 Reverse Proxy
ProxyPass "/cgi-bin/" !
ProxyPass "/debug" !
# API → web-gateway
ProxyPass "/api/" "http://web-gateway:5000/api/"
ProxyPassReverse "/api/" "http://web-gateway:5000/api/"
# 其他 → frontend
ProxyPass "/" "http://frontend:3000/"
ProxyPassReverse "/" "http://frontend:3000/"
他會把 /api/ 開頭的網址的請求轉給 web-gateway,其他的請求則轉給 frontend。
site.conf
這個檔案就設定一些目錄權限而已。
<VirtualHost *:80>
ServerName localhost
DocumentRoot "/usr/local/apache2/htdocs"
Alias /cgi-bin/ "/usr/local/apache2/cgi-bin/"
ScriptAlias /debug "/usr/local/apache2/cgi-bin/debug.sh"
<Directory "/usr/local/apache2/htdocs">
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
<Directory "/usr/local/apache2/cgi-bin">
AllowOverride None
Options +ExecCGI
AddHandler cgi-script .sh
Require all granted
</Directory>
</VirtualHost>
Dockerfile & entrypoint.sh
誒重點來了,這裡的這兩個檔案大有學問
FROM httpd:2.4.49
COPY httpd.conf /usr/local/apache2/conf/httpd.conf
COPY conf.d/ /usr/local/apache2/conf/conf.d/
COPY html/ /usr/local/apache2/htdocs/
COPY cgi-bin/ /usr/local/apache2/cgi-bin/
# 修正 Windows CRLF,並確保 CGI 可執行
RUN sed -i 's/\r//' /usr/local/apache2/cgi-bin/debug.sh \
&& sed -i 's/\r//' /usr/local/apache2/cgi-bin/test-cgi.sh \
&& chmod +x /usr/local/apache2/cgi-bin/debug.sh \
&& chmod +x /usr/local/apache2/cgi-bin/test-cgi.sh
# 安裝 SSH server, vim, sudo
RUN sed -i 's|deb.debian.org|archive.debian.org|g' /etc/apt/sources.list \
&& sed -i 's|security.debian.org|archive.debian.org|g' /etc/apt/sources.list \
&& apt-get update -o Acquire::Check-Valid-Until=false \
&& apt-get install -y --no-install-recommends openssh-server sudo vim \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /var/run/sshd \
&& useradd -m -s /bin/bash opadmin
RUN printf '%s\n' \
'opadmin ALL=(root) NOPASSWD: /usr/bin/vim' \
> /etc/sudoers.d/opadmin \
&& chmod 440 /etc/sudoers.d/opadmin \
&& visudo -cf /etc/sudoers.d/opadmin
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
EXPOSE 80 22
entrypoint.sh#!/bin/sh set -eu # ------------------------------------------------------------ # Apache flag # ------------------------------------------------------------ chown opadmin:opadmin /flag.txt chmod 644 /flag.txt echo "opadmin:${SSH_PIVOT_PASSWORD}" | chpasswd /usr/sbin/sshd exec httpd-foreground
由於我們 Stage6、7 的設計是用 ssh 連回 Apache、用 vim 和 sudo 配置錯誤進行提權,我們需要安裝 ssh, vim, sudo 並做相關設定、開啟服務。這裡遇到的問題是 Apache 2.4.49 是遠古版本,直接 apt install 會報錯。於是我們需要先把 sources.list 中的倉庫網址都換成 archive,並用 apt-get update -o Acquire::Check-Valid-Until=false 而非 apt-get update(因為我們現在在用一個已經 Archive 的 Debian 版本)
外部介面
這裡指的是登入前的入口 / 介紹頁面。我們用 Bootstrap 當框架。然後開始 vibe
之後把頁面註冊到 app.py
小彩蛋:猜猜我把
/robots.txt指向哪裡 😉
資料庫
資料庫拿來存註冊的帳號密碼、帳號操作記錄、證書核發狀態。我們使用 postgres 來做。以下是 init.sql
init.sqlCREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, username VARCHAR(80) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, role VARCHAR(20) NOT NULL DEFAULT 'user', email VARCHAR(255), display_name VARCHAR(100) DEFAULT NULL, exam_type VARCHAR(20), created_at TIMESTAMP DEFAULT NOW() ); -- Display Name 預設為帳號名稱,並補齊既有資料。 UPDATE users SET display_name = username WHERE display_name IS NULL OR BTRIM(display_name) = ''; CREATE TABLE IF NOT EXISTS activity_log ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id), action VARCHAR(100) NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS certificates ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, scores JSONB NOT NULL, average_score NUMERIC(5,2) NOT NULL, grade VARCHAR(5) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', file_name VARCHAR(255), issued_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), CONSTRAINT certificates_status_check CHECK (status IN ('pending', 'issued', 'failed')) );
註冊與登入系統、權限管理
這一樣是前端的部分,我們建立了 register.html 與 login.html,並讓 auth.js 去 POST API 進行註冊與登入。耶我們的後端首次亮相了!為了將來解釋順利,先簡要說明整個後端的架構。後端目錄如下:
src
├── app.py
├── assets
│ └── materials
│ ├── boyfriend_manual.pdf
│ ├── pwn01.pdf
│ ├── red_team_intern.pdf
│ └── web_security.pdf
├── certificate_service.py
├── db.py
├── decorators.py
├── flag.txt
└── routes
├── admin
│ ├── flags.py
│ ├── __init__.py
│ ├── users.py
│ └── webhook.py
├── auth.py
├── materials.py
└── user
├── activity.py
├── certificate.py
├── __init__.py
├── password.py
├── profile.py
很明顯 assets 是放教材中心教材的地方,routes 是放各個 API 的地方,app.py 是 Flask app 入口。
所有連線、操作資料庫的函式定義於 db.py,certificate_service.py 則是產生證書的服務。
decorators.py 則是放一些裝飾器,具體而言定義了以下四個裝飾器:
require_login_page:檢查使用者是否登入,若未登入則導向登入頁面require_admin_page:在以登入的基礎下檢查使用者是否為管理員,若不是則 403redirect_if_logged_in:用於/register與/login,若使用者已登入則導向/panelredirect_if_admin:訪問/user/底下的頁面時,如果使用者是管理員則導向/admin/(將/user/替換為/admin/)
app.py 的程式碼如下:
app.pyimport atexit from flask import Flask from routes.auth import auth_bp from routes.admin import register_admin_blueprints from routes.user import register_user_blueprints from db import close_pool from routes.materials import material_bp def create_app(): app = Flask(__name__) app.register_blueprint(auth_bp) register_admin_blueprints(app) register_user_blueprints(app) app.register_blueprint(material_bp) return app app = create_app() atexit.register(close_pool) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)
我們來看用於登入登出、權限管理的 routes/auth.py。裡面中規中矩定義了 register, login, logout,以及用來回傳目前使用者名稱、權限的 me 四個 API。檢查帳號是否存在、和資料庫互動相關就不多說,重點是 register 與 login 實作中,處裡 JWT 的部分使用了:
body = '{' \
+ '"role": "' + "user" \
+ '", "username": "' + str(username) \
+ '"}'
這是我們特意設計的 JSON Injection 漏洞。字串拼接後會變 {"role": "user", "username": "<username>"},因此只要玩家輸入 admin", "role": "admin 作為使用者名稱,直接拼接就會成為 {"role": "user", "username": "admin", "role": "admin"}。而 Python 的 json.loads() 如果有兩個同名的 Key 會用後面的取代前面的,因此 json.loads(body) 會得到 {'role': 'admin', 'username': 'admin'},進而得到管理員權限。
教材下載功能:Dump 後端
一樣 vibe 一個前端頁面去打 /api/materials 取得教材。GET /api/materials 會返回教材列表,POST api/materials/read 則會返回教材內容(檔案名稱由 file 參數傳入)。read API 的程式碼如下:
@material_bp.post("/read")
@require_login
def read_material():
filename = request.form.get("file")
if not filename:
return jsonify({
"error": "missing file"
}), 400
path = os.path.join(MATERIAL_DIR, filename)
if not os.path.isfile(path):
return jsonify({
"error": "file not found"
}), 404
return send_file(path)
路徑用直接拼接的,於是有 Path Traversal。
Admin Webhook:SSRF
搞錯啦!這也不是 Webhook 啊是 URL Fetcher
算了又是一個沒搞清楚取錯的例子XD
這裡我們不能直接用 requests,因為他沒支援 Gopher 協議,這樣我們的漏洞就不成立了,所以我們選擇採用預設支援 Gopher 的 pycurl
def _fetch_url(target_url):
parsed = urllib.parse.urlparse(target_url)
if not parsed.scheme or not parsed.hostname:
raise ValueError('Invalid URL format')
if parsed.hostname in ('127.0.0.1', 'localhost'):
raise PermissionError('Blocked: cannot connect to loopback address')
buffer = BytesIO()
c = pycurl.Curl()
try:
c.setopt(pycurl.CAINFO,"/etc/ssl/certs/ca-certificates.crt")
c.setopt(pycurl.URL, target_url)
c.setopt(pycurl.WRITEDATA, buffer)
c.setopt(pycurl.TIMEOUT, 5)
c.perform()
status = c.getinfo(pycurl.RESPONSE_CODE)
body = buffer.getvalue().decode('utf-8', errors='replace')
return status, body
except pycurl.error as e:
args = e.args
if len(args) == 2:
errno, errstr = args
else:
errno = None
errstr = args[0] if args else ''
partial = buffer.getvalue()
if errno == 28 and partial:
return 'partial_response_on_timeout', partial.decode('utf-8', errors='replace')
raise RuntimeError(errstr)
finally:
c.close()
可以看到我們很雞肋的擋了 127.0.0.1、localhost。
為何是 if errno == 28 回傳 timeout 呢?因為他是 CURLE_OPERATION_TIMEDOUT 的錯誤碼。這個步驟是就算 timeout,如果已經有接收到部分內容那也將他回傳。這步驟對我們的靶機是必要的,因為在 Stage 2 SSRF 打 Redis 時,取得資料後連線不會以預期方式結束而導致 timeout,若不回傳部分內容就會導致玩家拿不到資料。然後一樣去註冊一個 API:webhook-test,內容很簡單,就是 call 上述的 _fetch_url,然後把 status 跟 body 回傳。這部分以及上述的程式碼都在 web-gateway/routes/admin/webhook.py。一樣給他配個前端放在 Admin panel。
其他
我們也實作了帳號活動記錄、更改密碼、管理員管理使用者等功能,但由於他們和漏洞利用無關所以不特別說明。
總感覺為了完整性當時花了好一陣時間做了 Demo 跟打靶機時根本不會用到的功能XD 比如
?next。
證書系統:SSRF 目標
Redis
沒有特別的設定,畢竟他只要站在那裡被我們打就好了 可以看到與他相關的就在 Docker-compose.yml 中:
redis:
image: redis:7-alpine
networks:
- internal_net
比較有趣的是玩家怎麽知道要打 Redis 呢?我們設計了一個 API /api/admin/flag:
flags.pyimport os import redis from flask import Blueprint, jsonify from decorators import require_admin flags_bp = Blueprint("admin_flags", __name__, url_prefix="/api/admin") def _redis_client(): return redis.Redis( host=os.environ.get("REDIS_HOST", "redis"), port=int(os.environ.get("REDIS_PORT", "6379")), decode_responses=True, ) @flags_bp.get("/flag") @require_admin def get_stage2_flag(): # Stage 3's flag is seeded only after the player has demonstrated # administrator access. It is intentionally stored in Redis so that the # next step requires the SSRF/gopher primitive rather than another Flask # endpoint. stage3_flag = os.environ.get("FLAG_STAGE3") if stage3_flag: _redis_client().set("ctf:flag:stage3", stage3_flag) return jsonify({ "stage": 2, "flag": os.environ.get("FLAG_STAGE2", "FLAG_STAGE2_NOT_CONFIGURED"), })
玩家 Dump 出後端程式碼一看就知道要幹嘛啦!
Celery Worker
他是拿來產生證書用的,漏洞點是接受 Pickle 作為序列化的格式。眾所周知,反序列化惡意 Pickle 可能導致 RCE。
celery_app.pyimport os from celery import Celery redis_host = os.environ.get('REDIS_HOST', 'redis') redis_port = os.environ.get('REDIS_PORT', '6379') broker_url = f'redis://{redis_host}:{redis_port}/0' app = Celery('ais3_tasks', broker=broker_url, backend=broker_url, include=['tasks']) app.conf.update( task_serializer='pickle', result_serializer='pickle', accept_content=['pickle'], # ---------------------------------------- task_routes={ 'tasks.generate_certificate': {'queue': 'celery'}, } )
以 celeryuser 權限把 Celery Worker 開起來
entrypoint.shexec su -s /bin/bash celeryuser -c \ "celery -A celery_app worker --loglevel=info --queues=celery"
產生證書用的 tasks.py 叫 AI 生一生就好了,他的工作基本上就是產生一個有文字有雷達圖的 pdf。
這樣若玩家能在 Redis 推入一個惡意的 Task,然後 Celery Worker 拿出來執行,就會在反序列化 Pickle 時執行我們的指令。
一樣的問題,玩家怎麽知道後面有 Celery Worker,而且他還接受 Pickle 所以要打 Pickle 反序列化漏洞呢?一樣在後端的程式碼!從 certificate_service.py 中的以下段落可看出:
certificate_service.pydef _celery_client(): broker = f"redis://{os.environ.get('REDIS_HOST', 'redis')}:{os.environ.get('REDIS_PORT', '6379')}/0" celery = Celery('certificate_dispatch', broker=broker) celery.conf.update( task_serializer='pickle', result_serializer='pickle', accept_content=['pickle'], task_default_queue='celery', ) return celery
Celery Worker LPE
這部分我們選擇使用 cron 和 tar argument injection 來做。
Tar Argument Injection 我在之前自己出的 CTF 也玩過:File Manager Official Writeup
具體而言,建立一個 crontab:
# AIS3 DevOps 助教備份任務
# 每分鐘備份學員作業提交 Log (以 root 身份執行)
* * * * * root cd /var/log/app && tar -czf /var/backups/submissions_log.tar.gz * 2>/dev/null
要是 /var/log/app 權限沒設好,導致使用者可以在目錄下新增檔案就會導致 * 展開為檔案名稱,之後被誤判成指令參數。
為了能達成,Dockerfile 裡加入:
RUN mkdir -p /var/log/app /var/backups && \
chmod 777 /var/log/app && \
chmod 755 /var/backups
COPY crontab /etc/cron.d/ais3-backup
RUN chmod 0644 /etc/cron.d/ais3-backup && \
crontab /etc/cron.d/ais3-backup
打回 Apache
這一樣是那次挑燈夜戰討論出來的,我們當時在想怎麽合理的讓 Celery Worker 能打回 Apache。有助教說可以放 ssh 連線指令在 bash history,而他是用密碼連進去的。我們來看 entrypoint.sh 相關的段落:
entrypoint.sh# 設定 SSH mkdir -p /root/.ssh cat > /root/.ssh/config << 'EOF' Host ingress HostName ingress User opadmin StrictHostKeyChecking no UserKnownHostsFile /dev/null EOF chmod 700 /root/.ssh chmod 600 /root/.ssh/config # 順手留一則備忘,強化「root 平常真的會手動連 ingress」的敘事 cat > /root/ops-notes.txt << EOF 2026-02 記得每次 log rotate 完要手動上 ingress 確認 apache 有正常重載 帳號 opadmin,密碼問 infra 群組置頂 之後要把這個流程也寫進 crontab 自動化 # Port 22 會被 map 到 2222 EOF chmod 644 /root/ops-notes.txt # bash history cat > /root/.bash_history << EOF whoami hostname cd /var/log/app ls -lah tail -n 50 celery.log cd /var/backups ls -lah du -sh * ps aux | grep celery ps aux | grep cron crontab -l cd /var/log/app tail -n 100 celery.log cd /var/backups ls -lh tar -czf celery-logs.tar.gz -C /var/log app ls -lh celery-logs.tar.gz cat /root/ops-notes.txt sshpass -p '${SSH_PIVOT_PASSWORD}' ssh opadmin@ingress whoami hostname pwd ls -lah cd /var/log ls -lah ps aux | grep ssh df -h exit cd /var/log/app tail -n 20 celery.log cd /var/backups ls -lh exit EOF chmod 600 /root/.bash_history # 把這幾個檔案的時間戳往前調,避免全部都跟 container 啟動時間一樣新, # 看起來像是長期累積下來的操作紀錄而不是剛生出來的 touch -d "3 days ago" /root/ops-notes.txt touch -d "10 days ago" /root/.bashrc /root/.ssh/config touch -d "1 hour ago" /root/.bash_history # 把密碼從環境變數移除 unset SSH_PIVOT_PASSWORD
你看我們為了真實性還把那些檔案的產生日期往前調,用不用心:D
雖然應該大家也不會注意這種小細節嗚嗚
最後一步 unset SSH_PIVOT_PASSWORD 很重要,避免玩家沒提權在 Stage 4 直接讀環境變數就拿到密碼。
要避免非預期坑也是挺多w
Apache LPE
LPE 還有很多種方法,比如 Try Hack Me 就有幾個 Room 整理了數種,但這時時間已經非常趕,所以我選了一個很簡單的:sudoers 指定使用者可以不用密碼以管理員權限執行 vim,而 vim 是能直接開 shell 的。
opadmin ALL=(root) NOPASSWD: /usr/bin/vim
心得與花絮
這篇文章大致帶大家探索了一臺靶機的誕生,雖然沒有深入每個實作細節(我們主要專注在漏洞設計的想法、怎麽實現「漏洞」部分和確保他打得了),但仍希望大家看完對我們做的靶機有個整體的概念。此外這是我第一次做靶機與寫這種文章,所以很多地方設計的不夠漂亮,這篇文也離完美還很遠,不過至少是個開始 😆
在過程中我發現靶機真的沒想象中容易做,即便在 AI 大力幫忙下還是花了很多時間去摸所、實作、修 bug。且靶機不是把漏洞塞進去就好,還要考慮怎麽讓漏洞們有連貫性,也要讓玩家有足夠線索知道怎麽解,甚至還要避免非預期 / 跳關的發生。
此外剛開始用 AI 生 code 有以下故事XD:AI 在後端程式碼寫了 redirect,因為我是一小段一小段生,一看就感覺不太對勁,這不是前端的工作嗎?於是叫 AI 重新生成。這故事告訴我們沒界定好要幹嘛 AI 是很容易亂做一通的w。
BTW 小花絮:我都跟別組的人說「你們好捲都線上討論」,但其實自己整天爆肝研究w







