mini-mqtt
mqtt 的题目也是第一次见,PolarisCTF 的质量确实很高,有 WEBPWN的复现环境也是很好了

程序分析:
main
1 | int __fastcall main(int argc, const char **argv, const char **envp) |
emmm 有点忘了,查了一下 argc,argv,envp 都表示什么:
- argc 指的就是命令的数量,比如
./mqtt_client tcp://192.168.1.100:1883的 argc 就是 2 - argv 指的是参数的具体内容,比如 argv[0] 的值是
"./mqtt_client" - envp 就是环境变量指针,不过我们一般是用不到他的
创建 MQTT 客户端实例
1 | rc = MQTTClient_create(&client, v5, "httpclient", 1, 0); |
初始化并创建一个 MQTT 客户端句柄。
&client:全局变量,用于存储创建好的客户端句柄。v5:上一步确定的服务器 URI(如tcp://localhost:9999)。"httpclient":Client ID(客户端标识符),在 MQTT 代理中必须是唯一的。1:持久化类型,1对应宏MQTTCLIENT_PERSISTENCE_NONE,表示在内存中保存状态,不写入磁盘。0:持久化上下文(无持久化时传 0 或 NULL)。- 错误处理:如果返回值
rc不为 0,说明创建失败,程序会打印错误码并将退出码设为 1。
设置回调函数
1 | rc = MQTTClient_setCallbacks(client, 0, connlost, msgarrvd, delivered); |
为客户端绑定异步事件的处理函数(由于是反编译代码,这些函数在别处定义)。
- 参数解析:
0:用户上下文指针(Context),这里为空。connlost:连接丢失时触发的回调函数。msgarrvd:接收到订阅消息时触发的回调函数。delivered:消息成功送达(针对 QoS 1 或 2)时触发的回调函数。
配置链接参数
1 | dword_5028 = 20; |
这两行是反编译器对全局结构体 MQTTClient_connectOptions(连接选项)成员赋值的底层还原。
dword_5028 = 20:通常对应keepAliveInterval,表示心跳保活时间为 20 秒。dword_502C = 1:通常对应cleansession,表示开启清除会话(设为 1/true)。客户端断开后,代理服务器不会保留它的订阅和离线消息。
建立连接和主循环
1 | rc = MQTTClient_connect(client, conn_opts); |
- 连接服务器:调用
MQTTClient_connect发起连接(!rc意味着连接成功)。 - 订阅主题:成功连接后,调用
MQTTClient_subscribe(client, "HTTP", 1)订阅名为 “HTTP” 的主题,服务质量(QoS)级别设为 1(至少送达一次)。 - 死循环(业务逻辑):
sleep(1u):程序休眠 1 秒。msgsend("200"):调用内部自定义函数msgsend发送消息"200"(具体发送到哪个主题需查看msgsend的实现)。puts(...):终端打印"waiting for message\n"。- 注意:因为这里是
while (1)死循环,程序在此处会无限执行发送动作,同时后台线程会通过之前设置的msgarrvd回调函数来处理接收到的消息
清理与退出
1 | MQTTClient_destroy(&client); |
- 清理:如果连接失败(跳过了死循环),或者程序因为某种外部中断跳出了循环,最终会调用
MQTTClient_destroy销毁客户端,释放内存。 - 返回:返回状态码
rc(0 为成功,1 为失败)。
msgarrvd
1 | __int64 __fastcall msgarrvd(__int64 a1, const char *a2, int a3, __int64 a4) |
这个函数 msgarrvd 是我们在上一个 main 函数中看到的 MQTT 接收消息的回调函数,对应 MQTTClient_setCallbacks 中的 msgarrvd
每当客户端订阅的主题(比如之前的 "HTTP" 主题)收到新消息时,MQTT 底层库就会自动调用这个函数。
它的核心逻辑是:检查收到的消息,如果是自己(或者标识为 “httpclient” 的设备)发出的 JSON 消息则直接丢弃;如果是其他消息,则交由一个叫 http() 的核心函数去处理并打印出来。
参数还原与理解
在 Paho MQTT C 库中,这个回调函数的标准签名其实是这样的:
int msgarrvd(void *context, char *topicName, int topicLen, MQTTClient_message *message)
对应到反编译的伪代码中:
a1(v8) =context(上下文指针,这里没用到)a2(v7) =topicName(收到消息的主题名称)a3(v6) =topicLen(主题名称的长度)a4(v5) =message(指向MQTTClient_message结构体的消息对象指针)
提取消息内容
1 | v9 = *(_QWORD *)(a4 + 16); |
在 64 位系统中,MQTTClient_message 结构体偏移 16 字节(a4 + 16)的位置存放的是 payload,也就是真正的消息内容(字符串或二进制数据)。
这一步将消息内容的指针赋给了 v9。
消息过滤(if 分支)
1 | if ( (unsigned int)__isoc99_sscanf(v9, "{\"clientid\":\"%63[^\"]\",", s1) == 1 && !strcmp(s1, "httpclient") ) |
- 解析 JSON:使用
sscanf尝试按照特定的 JSON 格式解析收到的消息v9。它在寻找{"clientid":"开头的内容,并把双引号里面的字符串提取到字符数组s1中(最多提取 63 个字符)。 - ****如果成功提取到了
clientid,并且strcmp比较发现这个 ID 刚好是"httpclient"。 - 满足上述条件说明这是自己发出的消息,或者是伪造成自己发出的消息。程序会调用释放内存的函数,清理消息体和主题字符串,然后直接 return 1 结束处理,不做任何额外动作。
- 这种设计通常是为了防止回音现象(自己订阅了主题,又往这个主题发消息,导致自己收到自己的消息)。
处理其他消息(else 分支)
如果消息格式不匹配,或者 clientid 不是 "httpclient",程序就会进入 else 分支:
1 | else |
- 程序将收到的消息内容(
v9)原封不动地传给了一个名为http()的函数。这是一个自定义函数,整个程序真正的核心业务逻辑大概率就在这个http()函数里面。 - 打印日志:
- 打印主题名称:
topic: [主题名] - 打印消息内容:
printf中的%.*s是一个 C 语言的高级用法,它会根据前面的参数指定打印长度。这里*(_DWORD *)(v5 + 8)获取的是payloadlen(消息长度),*(const char )(v5 + 16)是payload(消息内容),合起来就是精确地打印出收到的所有内容。
- 打印主题名称:
- 清理内存:同样释放消息体和主题占用的内存。
- 返回 1:告诉 MQTT 库,这条消息已经被成功接收并处理完了。
所有非自身的 MQTT 消息输入,最终都交给了 http() 处理。所以我们主要分析的应该是 http 函数当中是否存在一定的漏洞:
http
1 | __int64 __fastcall http(const char *a1) |
这个 http 函数是整个程序的核心业务逻辑,也是正道题目真正的漏洞点所在。
它的本意是接收一个类似 HTTP 的 GET 请求,解析出想要读取的文件名,安全检查后,通过系统命令 cat 读取该文件(实际上被硬编码限制为只能读取 index_html),并通过 MQTT 把文件内容发回去。
变量初始化与自发消息过滤
1 | if ( (unsigned int)__isoc99_sscanf(a1, "{\"clientid\":\"%63[^\"]\"", s1) == 1 && !strcmp(s1, "httpclient") ) |
- 与外部回调函数类似,防止处理自己发出的 JSON 心跳包,直接返回。
解析 HTTP 请求并提取文件名
1 | if ( (unsigned int)__isoc99_sscanf(a1, "GET /home/ctf/%63[^ \"\r\n/]", s) == 1 ) |
- 功能:程序尝试用 4 种不同的正则表达式匹配收到的消息
a1。 - 目标:提取出
GET请求路径中/ctf/后面的文件名,存放到变量s中。 - 如果提取成功,标志位
v5会被置为1。
合法性校验
1 | if ( v5 ) |
- 提取长度:寻找 HTTP 头里的
ContentLength: %d,把数字存入v4。 - 长度限制:要求
ContentLength必须 小于等于 10,且文件名的长度不能超过它。 - 字符过滤:把文件名
s里的斜杠/和点.全部替换成下划线_,防止攻击者用../../../etc/passwd这种路径穿越攻击。
构造系统命令
1 | snprintf(src, 0x80u, "cat /home/ctf/%s", s); |
- 用
snprintf拼接出完整的 Linux 读取命令,放到局部变量 src 里,例如:cat /home/ctf/index_html - 拷贝到全局变量:计算
src的长度v7,然后使用memcpy将它拷贝到全局变量cmd中。 strlen(src)不包含字符串结尾的\0(空字符)。memcpy只拷贝了可见字符,没有把\0拷贝过去。如果cmd里面原本就有很长的数据,这次拷贝只会覆盖前半部分,后半部分依然残留!
执行命令并返回结果
1 | if ( !strcmp(s, "index_html") ) |
- 程序最后还强制要求提取出来的文件名
s必须完全等于"index_html"。 - 如果完全等于,就调用底层危险函数
popen(cmd, "r")执行全局变量cmd里的系统命令,把执行结果逐行通过msgsend传回去。
利用 EXP 进行 RCE:
这道题进行了如下防护:只能叫 index_html,长度限制 <= 10,过滤了 / 和 .。
首先利用io.send(mqtt_publish(b'HTTP', b'GET /home/ctf/index_html;cat<flag'))
- 提取出的文件名
s是index_html;cat<flag。 - 此时没有
ContentLength,v5会变回 0,后面的popen绝对不会执行。 - 但是程序依然执行了
snprintf和memcpy! src变成了:cat /home/ctf/index_html;cat<flag(长度 32)。- 32 个字节被拷贝到了全局变量
cmd中。此时cmd=cat /home/ctf/index_html;cat<flag。
io.send(mqtt_publish(b'HTTP', b'GET /home/ctf/index_html HTTP/1.1\r\nHost: x\r\nContentLength: 10\r\n'))
- 提取出的文件名
s是合法的index_html。 ContentLength为 10,完全满足所有的苛刻检查。src拼接变成了合法的:cat /home/ctf/index_html(长度 24)。- 当
memcpy(cmd, src, 24)开始执行。它只覆盖了全局变量cmd的前 24 个字节! - 此时
cmd的变化:- 原本的
cmd:cat /home/ctf/index_html;cat<flag - 被覆盖 24 字节后:
cat /home/ctf/index_html;cat<flag,后面的恶意命令依旧存在
- 原本的
- 接下来检查:
if ( !strcmp(s, "index_html") ),因为此时的s确实是index_html,检查通过 - 执行
popen(cmd, "r")。 - 实际丢给系统的命令变成了:
cat /home/ctf/index_html;cat<flag。系统会先 cat 网页文件,然后执行恶意命令拿到 flag
总 EXP(remote):
1 | from pwn import * |

更新: 2026-05-21 00:44:41
原文: https://www.yuque.com/idcm/wnemg9/pnyxh810a0egmtym