Skip to content

Commit 9d3e556

Browse files
committed
Reject null chars in strings
1 parent 03667b4 commit 9d3e556

5 files changed

Lines changed: 263 additions & 9 deletions

File tree

ChangeLog.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
## Release Notes
22

3+
### v2.0.1 (Pending)
4+
5+
* Security Hardening
6+
- Reject MQTT UTF-8 encoded strings containing U+0000 in `MqttDecode_String`
7+
per [MQTT-1.5.3-2] / [MQTT-1.5.4-2]. Closes an embedded-NUL truncation
8+
attack that allowed broker-side auth bypass, ClientId collision, and
9+
topic-routing confusion when the broker compared decoded strings with
10+
C-string semantics. The check is also applied to the CONNECT Password
11+
field (which the spec defines as Binary Data) because wolfMQTT compares
12+
passwords with `XSTRLEN`/`XSTRCMP`.
13+
14+
* API / Behavior Changes
15+
- `MqttDecode_String` may now return `MQTT_CODE_ERROR_MALFORMED_DATA`
16+
when the decoded string contains an embedded NUL byte. Previously only
17+
`MQTT_CODE_ERROR_BAD_ARG` and `MQTT_CODE_ERROR_OUT_OF_BUFFER` were
18+
possible negative returns.
19+
- `MqttDecode_Publish` now propagates the underlying error from
20+
`MqttDecode_String` (e.g. `MALFORMED_DATA`) instead of always returning
21+
`MQTT_CODE_ERROR_OUT_OF_BUFFER` on topic decode failure.
22+
- `MqttDecode_Props` similarly now propagates the underlying error from
23+
`MqttDecode_String` for v5 STRING and STRING_PAIR property types
24+
(Reason String, Content Type, User Property, etc.) instead of always
25+
masking it as `MQTT_CODE_ERROR_PROPERTY`.
26+
- New `XMEMCHR(s, c, n)` portability macro added to `mqtt_types.h`
27+
(defaults to `memchr`). Builds using `WOLFMQTT_CUSTOM_STRING` must
28+
provide their own mapping, matching the pattern used for `XSTRLEN`,
29+
`XMEMCMP`, etc.
30+
331
### v2.0.0 (03/20/2026)
432
Release 2.0.0 has been developed according to wolfSSL's development and QA
533
process (see link below) and successfully passed the quality criteria.

src/mqtt_broker.c

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,17 @@ static int BrokerStrCompare(const char* a, const char* b, int cmp_len)
175175
#endif /* WOLFMQTT_BROKER_AUTH */
176176

177177
/* Store a string of known length into a BrokerClient field.
178-
* Static mode: copies into fixed-size buffer with truncation.
179-
* Dynamic mode: frees old value, allocates new buffer, copies. */
178+
* Static mode: copies into fixed-size buffer with truncation. The
179+
* sensitive-wipe variant zeros the full max_len buffer, so it is
180+
* unaffected by string contents.
181+
* Dynamic mode: frees old value, allocates new buffer, copies. The
182+
* sensitive-wipe path uses XSTRLEN(buf)+1, which is correct only when
183+
* stored values contain no embedded NUL. MqttDecode_String enforces
184+
* [MQTT-1.5.3-2] / [MQTT-1.5.4-2] (rejects U+0000), so any string that
185+
* reaches this function via the protocol path satisfies that invariant.
186+
* Do not call the dynamic-mode sensitive path with src bytes that bypass
187+
* that decoder without first capping src_len at the first NUL, or the
188+
* wipe will leave trailing bytes uncleared. */
180189
#ifdef WOLFMQTT_STATIC_MEMORY
181190
static void BrokerStore_String(char* dst, int max_len,
182191
const char* src, word16 src_len)

src/mqtt_packet.c

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,19 @@ int MqttDecode_String(byte *buf, const char **pstr, word16 *pstr_len, word32 buf
347347
return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER);
348348
}
349349
buf += len;
350+
/* [MQTT-1.5.3-2] / [MQTT-1.5.4-2]: a UTF-8 encoded string MUST NOT
351+
* include U+0000. Reject so downstream C-string handling cannot be
352+
* tricked by an embedded NUL truncating the value.
353+
*
354+
* The CONNECT Password field is technically Binary Data per spec,
355+
* not a UTF-8 string, so the prohibition does not formally apply.
356+
* The check is still enforced here because wolfMQTT compares
357+
* passwords with XSTRLEN/XSTRCMP, so a binary password with an
358+
* embedded NUL would be silently truncated and enable an auth
359+
* bypass — exactly the class of attack this validation prevents. */
360+
if (str_len > 0 && XMEMCHR(buf, 0x00, str_len) != NULL) {
361+
return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_MALFORMED_DATA);
362+
}
350363
if (pstr_len) {
351364
*pstr_len = str_len;
352365
}
@@ -640,7 +653,10 @@ int MqttDecode_Props(MqttPacketType packet, MqttProp** props, byte* pbuf,
640653
(const char**)&cur_prop->data_str.str,
641654
&cur_prop->data_str.len,
642655
(word32)(buf_len - (buf - pbuf)));
643-
if ((tmp >= 0) && ((word32)tmp <= (buf_len - (buf - pbuf)))) {
656+
if (tmp < 0) {
657+
rc = tmp;
658+
}
659+
else if ((word32)tmp <= (buf_len - (buf - pbuf))) {
644660
buf += tmp;
645661
total += tmp;
646662
prop_len -= (word32)tmp;
@@ -702,7 +718,10 @@ int MqttDecode_Props(MqttPacketType packet, MqttProp** props, byte* pbuf,
702718
(const char**)&cur_prop->data_str.str,
703719
&cur_prop->data_str.len,
704720
(word32)(buf_len - (buf - pbuf)));
705-
if ((tmp >= 0) && ((word32)tmp <= (buf_len - (buf - pbuf)))) {
721+
if (tmp < 0) {
722+
rc = tmp;
723+
}
724+
else if ((word32)tmp <= (buf_len - (buf - pbuf))) {
706725
buf += tmp;
707726
total += tmp;
708727
prop_len -= (word32)tmp;
@@ -711,8 +730,11 @@ int MqttDecode_Props(MqttPacketType packet, MqttProp** props, byte* pbuf,
711730
(const char**)&cur_prop->data_str2.str,
712731
&cur_prop->data_str2.len,
713732
(word32)(buf_len - (buf - pbuf)));
714-
if ((tmp >= 0) && ((word32)tmp <=
715-
(buf_len - (buf - pbuf)))) {
733+
if (tmp < 0) {
734+
rc = tmp;
735+
}
736+
else if ((word32)tmp <=
737+
(buf_len - (buf - pbuf))) {
716738
buf += tmp;
717739
total += tmp;
718740
prop_len -= (word32)tmp;
@@ -1464,12 +1486,13 @@ int MqttDecode_Publish(byte *rx_buf, int rx_buf_len, MqttPublish *publish)
14641486
/* Decode variable header */
14651487
variable_len = MqttDecode_String(rx_payload, &publish->topic_name,
14661488
&publish->topic_name_len, (word32)(rx_buf_len - (rx_payload - rx_buf)));
1467-
if ((variable_len >= 0) && (variable_len + header_len <= rx_buf_len)) {
1468-
rx_payload += variable_len;
1489+
if (variable_len < 0) {
1490+
return variable_len;
14691491
}
1470-
else {
1492+
if (variable_len + header_len > rx_buf_len) {
14711493
return MQTT_TRACE_ERROR(MQTT_CODE_ERROR_OUT_OF_BUFFER);
14721494
}
1495+
rx_payload += variable_len;
14731496

14741497
/* If QoS > 0 then get packet Id */
14751498
if (publish->qos > MQTT_QOS_0) {

tests/test_mqtt_packet.c

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,23 @@ TEST(decode_publish_malformed_variable_exceeds_remain)
555555
ASSERT_EQ(MQTT_CODE_ERROR_OUT_OF_BUFFER, rc);
556556
}
557557

558+
/* [MQTT-1.5.3-2] / [MQTT-4.7.3-2]: a topic name containing U+0000 must be
559+
* rejected. Without this check, downstream broker logic uses C-string
560+
* semantics on the stored topic and a publish to "se\0cret" would route
561+
* to subscribers of "se". */
562+
TEST(decode_publish_rejects_nul_in_topic)
563+
{
564+
byte buf[] = { 0x30, 10,
565+
0x00, 0x07, 's', 'e', 0x00, 'c', 'r', 'e', 't',
566+
'X' };
567+
MqttPublish pub;
568+
int rc;
569+
570+
XMEMSET(&pub, 0, sizeof(pub));
571+
rc = MqttDecode_Publish(buf, (int)sizeof(buf), &pub);
572+
ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc);
573+
}
574+
558575
#ifdef WOLFMQTT_V5
559576
/* Hand-validated MQTT v5 PUBLISH packet (independent oracle, not produced by
560577
* MqttEncode_Publish) so encode and decode cannot hide a shared bug:
@@ -1501,6 +1518,98 @@ TEST(decode_connect_v311_with_lwt)
15011518
ASSERT_NOT_NULL(dec_lwt.buffer);
15021519
ASSERT_EQ(0, XMEMCMP(dec_lwt.buffer, lwt_payload, sizeof(lwt_payload)));
15031520
}
1521+
1522+
/* [MQTT-1.5.3-2]: an embedded NUL in the ClientId must be rejected.
1523+
* Otherwise BrokerClient_FindByClientId() (which uses XSTRCMP) will treat
1524+
* "ad\0min" as "ad" and collide with an existing "ad" session. */
1525+
TEST(decode_connect_rejects_nul_in_client_id)
1526+
{
1527+
byte buf[] = {
1528+
0x10, 0x12, /* CONNECT, remain_len = 18 */
1529+
0x00, 0x04, 'M', 'Q', 'T', 'T', /* protocol name */
1530+
0x04, /* protocol level v3.1.1 */
1531+
0x02, /* flags: clean_session */
1532+
0x00, 0x3C, /* keep alive */
1533+
0x00, 0x06, 'a', 'd', 0x00, 'm', 'i', 'n' /* client_id with NUL */
1534+
};
1535+
MqttConnect dec;
1536+
int rc;
1537+
1538+
XMEMSET(&dec, 0, sizeof(dec));
1539+
rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec);
1540+
ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc);
1541+
}
1542+
1543+
/* [MQTT-1.5.3-2]: an embedded NUL in the username must be rejected.
1544+
* Otherwise BrokerStrCompare() (which uses XSTRLEN) will treat
1545+
* "us\0er" as "us" and accept it against a configured "us" credential. */
1546+
TEST(decode_connect_rejects_nul_in_username)
1547+
{
1548+
byte buf[] = {
1549+
0x10, 0x15, /* CONNECT, remain_len = 21 */
1550+
0x00, 0x04, 'M', 'Q', 'T', 'T',
1551+
0x04,
1552+
0x82, /* clean_session + USERNAME */
1553+
0x00, 0x3C,
1554+
0x00, 0x02, 'c', '1', /* client_id "c1" */
1555+
0x00, 0x05, 'u', 's', 0x00, 'e', 'r' /* username with NUL */
1556+
};
1557+
MqttConnect dec;
1558+
int rc;
1559+
1560+
XMEMSET(&dec, 0, sizeof(dec));
1561+
rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec);
1562+
ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc);
1563+
}
1564+
1565+
/* [MQTT-1.5.3-2]: an embedded NUL in the password must be rejected.
1566+
* Same auth-bypass mechanism as the username test, applied to the
1567+
* password field. */
1568+
TEST(decode_connect_rejects_nul_in_password)
1569+
{
1570+
byte buf[] = {
1571+
0x10, 0x16, /* CONNECT, remain_len = 22 */
1572+
0x00, 0x04, 'M', 'Q', 'T', 'T',
1573+
0x04,
1574+
0xC2, /* clean_session + USER + PASS */
1575+
0x00, 0x3C,
1576+
0x00, 0x02, 'c', '1',
1577+
0x00, 0x01, 'u', /* username "u" */
1578+
0x00, 0x03, 'p', 0x00, 'w' /* password with NUL */
1579+
};
1580+
MqttConnect dec;
1581+
int rc;
1582+
1583+
XMEMSET(&dec, 0, sizeof(dec));
1584+
rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec);
1585+
ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc);
1586+
}
1587+
1588+
/* [MQTT-1.5.3-2] / [MQTT-4.7.3-2]: a Will Topic with embedded NUL must
1589+
* be rejected. The same C-string truncation that affects PUBLISH topics
1590+
* applies to Will Topics persisted by the broker. */
1591+
TEST(decode_connect_rejects_nul_in_will_topic)
1592+
{
1593+
byte buf[] = {
1594+
0x10, 0x16, /* CONNECT, remain_len = 22 */
1595+
0x00, 0x04, 'M', 'Q', 'T', 'T',
1596+
0x04,
1597+
0x06, /* clean_session + WILL_FLAG */
1598+
0x00, 0x3C,
1599+
0x00, 0x02, 'c', '1', /* client_id */
1600+
0x00, 0x03, 't', 0x00, 'p', /* will topic with NUL */
1601+
0x00, 0x01, 'X' /* will payload */
1602+
};
1603+
MqttConnect dec;
1604+
MqttMessage lwt;
1605+
int rc;
1606+
1607+
XMEMSET(&dec, 0, sizeof(dec));
1608+
XMEMSET(&lwt, 0, sizeof(lwt));
1609+
dec.lwt_msg = &lwt;
1610+
rc = MqttDecode_Connect(buf, (int)sizeof(buf), &dec);
1611+
ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc);
1612+
}
15041613
#endif /* WOLFMQTT_BROKER */
15051614

15061615
/* ============================================================================
@@ -1564,6 +1673,28 @@ TEST(decode_subscribe_v311_qos3_reserved)
15641673
ASSERT_EQ(MQTT_QOS_3, topic_arr[0].qos);
15651674
}
15661675

1676+
/* [MQTT-1.5.3-2] / [MQTT-4.7.3-2]: a topic filter containing U+0000 must
1677+
* be rejected. Without this check, a stored filter "a\0b" would match
1678+
* topic "a" once iteration hits the embedded NUL in BrokerTopicMatch. */
1679+
TEST(decode_subscribe_rejects_nul_in_filter)
1680+
{
1681+
byte rx_buf[] = {
1682+
0x82, 0x08,
1683+
0x00, 0x01, /* packet_id */
1684+
0x00, 0x03, 'a', 0x00, 'b', /* filter "a\0b" */
1685+
0x00 /* options: QoS 0 */
1686+
};
1687+
MqttSubscribe sub;
1688+
MqttTopic topic_arr[1];
1689+
int rc;
1690+
1691+
XMEMSET(&sub, 0, sizeof(sub));
1692+
XMEMSET(topic_arr, 0, sizeof(topic_arr));
1693+
sub.topics = topic_arr;
1694+
rc = MqttDecode_Subscribe(rx_buf, (int)sizeof(rx_buf), &sub);
1695+
ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc);
1696+
}
1697+
15671698
#ifdef WOLFMQTT_V5
15681699
/* [MQTT-3.8.3] v5 SUBSCRIBE options byte carries QoS (bits 0-1), No Local
15691700
* (bit 2), Retain As Published (bit 3), and Retain Handling (bits 4-5).
@@ -1595,7 +1726,57 @@ TEST(decode_subscribe_v5_options_byte_qos_extracted)
15951726
ASSERT_EQ(1, sub.topic_count);
15961727
ASSERT_EQ(MQTT_QOS_1, topic_arr[0].qos);
15971728
}
1729+
1730+
/* [MQTT-1.5.4-2]: an embedded NUL in a v5 STRING property must be
1731+
* rejected. Uses a PUBLISH packet with a Content Type property whose
1732+
* value contains 0x00. The MqttDecode_Props path now propagates the
1733+
* underlying MALFORMED_DATA from MqttDecode_String instead of masking
1734+
* it as MQTT_CODE_ERROR_PROPERTY. */
1735+
TEST(decode_publish_v5_rejects_nul_in_string_property)
1736+
{
1737+
/* Wire: PUBLISH QoS 0, remain_len=13, topic "a/b", props_len=7,
1738+
* prop 0x03 CONTENT_TYPE, str_len=4, "t\0xt". No payload. */
1739+
byte buf[] = {
1740+
0x30, 13,
1741+
0x00, 0x03, 'a', '/', 'b',
1742+
0x07,
1743+
0x03, 0x00, 0x04, 't', 0x00, 'x', 't'
1744+
};
1745+
MqttPublish pub;
1746+
int rc;
1747+
1748+
XMEMSET(&pub, 0, sizeof(pub));
1749+
pub.protocol_level = MQTT_CONNECT_PROTOCOL_LEVEL_5;
1750+
rc = MqttDecode_Publish(buf, (int)sizeof(buf), &pub);
1751+
ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc);
1752+
MqttProps_Free(pub.props);
1753+
}
15981754
#endif /* WOLFMQTT_V5 */
1755+
1756+
/* ============================================================================
1757+
* MqttDecode_Unsubscribe (broker-side)
1758+
* ============================================================================ */
1759+
1760+
/* [MQTT-1.5.3-2] / [MQTT-4.7.3-2]: a topic filter containing U+0000 in an
1761+
* UNSUBSCRIBE must be rejected — MqttDecode_Unsubscribe shares the same
1762+
* MqttDecode_String chokepoint that SUBSCRIBE uses. */
1763+
TEST(decode_unsubscribe_rejects_nul_in_filter)
1764+
{
1765+
byte rx_buf[] = {
1766+
0xA2, 0x07,
1767+
0x00, 0x01, /* packet_id */
1768+
0x00, 0x03, 'a', 0x00, 'b' /* filter "a\0b" */
1769+
};
1770+
MqttUnsubscribe unsub;
1771+
MqttTopic topic_arr[1];
1772+
int rc;
1773+
1774+
XMEMSET(&unsub, 0, sizeof(unsub));
1775+
XMEMSET(topic_arr, 0, sizeof(topic_arr));
1776+
unsub.topics = topic_arr;
1777+
rc = MqttDecode_Unsubscribe(rx_buf, (int)sizeof(rx_buf), &unsub);
1778+
ASSERT_EQ(MQTT_CODE_ERROR_MALFORMED_DATA, rc);
1779+
}
15991780
#endif /* WOLFMQTT_BROKER */
16001781

16011782
/* ============================================================================
@@ -2127,6 +2308,7 @@ void run_mqtt_packet_tests(void)
21272308
RUN_TEST(decode_publish_qos1_valid);
21282309
RUN_TEST(decode_publish_qos0_zero_payload);
21292310
RUN_TEST(decode_publish_malformed_variable_exceeds_remain);
2311+
RUN_TEST(decode_publish_rejects_nul_in_topic);
21302312
#ifdef WOLFMQTT_V5
21312313
RUN_TEST(decode_publish_v5_content_type_property);
21322314
#endif
@@ -2178,13 +2360,22 @@ void run_mqtt_packet_tests(void)
21782360
RUN_TEST(decode_connect_wrong_protocol_name);
21792361
RUN_TEST(decode_connect_wrong_protocol_length);
21802362
RUN_TEST(decode_connect_v311_with_lwt);
2363+
RUN_TEST(decode_connect_rejects_nul_in_client_id);
2364+
RUN_TEST(decode_connect_rejects_nul_in_username);
2365+
RUN_TEST(decode_connect_rejects_nul_in_password);
2366+
RUN_TEST(decode_connect_rejects_nul_in_will_topic);
21812367

21822368
/* MqttDecode_Subscribe */
21832369
RUN_TEST(decode_subscribe_v311_single_topic);
21842370
RUN_TEST(decode_subscribe_v311_qos3_reserved);
2371+
RUN_TEST(decode_subscribe_rejects_nul_in_filter);
21852372
#ifdef WOLFMQTT_V5
21862373
RUN_TEST(decode_subscribe_v5_options_byte_qos_extracted);
2374+
RUN_TEST(decode_publish_v5_rejects_nul_in_string_property);
21872375
#endif
2376+
2377+
/* MqttDecode_Unsubscribe */
2378+
RUN_TEST(decode_unsubscribe_rejects_nul_in_filter);
21882379
#endif
21892380

21902381
/* QoS 2 ack arithmetic */

wolfmqtt/mqtt_types.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ enum MqttPacketResponseCodes {
246246
#ifndef XMEMCMP
247247
#define XMEMCMP(s1,s2,n) memcmp((s1),(s2),(n))
248248
#endif
249+
#ifndef XMEMCHR
250+
#define XMEMCHR(s,c,n) memchr((s),(c),(n))
251+
#endif
249252
#ifndef XATOI
250253
#define XATOI(s) atoi((s))
251254
#endif

0 commit comments

Comments
 (0)