Skip to main content
Writeups

CVE-2026-95622: A cell broadcast that aborts ModemManager's CBM parser

Published
Length
8 min read
Target
ModemManager (main @ 306f6256)
Difficulty
Low
Topics
modemmanager / cellular / dos / assertion / linux / vulnerability-research / cve

ModemManager is the daemon that talks to your cellular modem on most Linux desktops and a lot of embedded gear. It runs as root, it sits on the system D-Bus, and it parses a stream of things the network sends it without asking. One of those things is cell broadcast, the channel that carries emergency alerts and area messages. This is a write up of a single byte in a cell broadcast message that walks the parser into g_assert_not_reached() and takes the whole daemon down with it.

The bug is not a memory corruption and it is not remote code execution. It is an assertion that the author put in as a “this can never happen” guard, sitting on a code path that the network can reach on purpose. That is the shape of it, and it is a common mistake.

At a glance

FieldValue
ClassCWE-617, reachable assertion
Componentsrc/mm-cbm-part.c, mm_cbm_part_new_from_binary_pdu()
ImpactRemote DoS of the root daemon, no auth, no interaction
CVSS 3.16.5 (Medium)
VectorAV:A/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
TriggerA cell broadcast PDU whose DCS selects an unhandled charset
ReachAT +CBM URC and the QMI broadcast path, both on unsolicited input
AffectedModemManager main at commit 306f6256
FixReject unhandled encodings with a GError instead of asserting

Cell broadcast, briefly

Cell broadcast is a one-to-many channel. The network pushes a short message to every phone in an area, and it is what carries wireless emergency alerts, the AMBER-alert style messages, and operator area notices. Your modem receives these without any request from the host, hands them up, and ModemManager parses them into its CellBroadcast D-Bus interface. That interface comes up automatically when the modem reports support for it, so there is no opt-in and no configuration a user has to get wrong first.

Two code paths feed the same parser:

  • The AT path: a +CBM unsolicited result code arrives, cbc_cbm_received() in src/mm-broadband-modem.c handles it, and it calls into mm_cbm_part_new_from_pdu().
  • The QMI path: add_new_read_cbm_part() in src/mm-broadband-modem-qmi.c handles a GSM_WCDMA_BROADCAST message and calls mm_cbm_part_new_from_binary_pdu() directly.

Both end up in the same function, and that function is where the problem is.

The default nobody set

mm_cbm_part_new_from_binary_pdu() starts by allocating the part:

cbm_part = mm_cbm_part_new ();

which is a g_slice_new0, so every field is zeroed. The encoding field is an MMSmsEncoding, and in that enum zero is not a neutral placeholder:

typedef enum {
    MM_SMS_ENCODING_UNKNOWN = 0x0,
    MM_SMS_ENCODING_GSM7,
    MM_SMS_ENCODING_8BIT,
    MM_SMS_ENCODING_UCS2,
} MMSmsEncoding;

So before the parser reads anything, cbm_part->encoding is MM_SMS_ENCODING_UNKNOWN. That is fine as long as every path through the parser overwrites it. Most of them do. One does not.

The data coding scheme, and the two charsets nobody handled

The parser reads the data coding scheme byte and masks off the top four bits to get the coding group, then decides the encoding. The general coding groups and the UDH group share a branch:

} else if ((group == CBS_DATA_CODING_GENERAL_CLASS) ||
           (group == CBS_DATA_CODING_GENERAL_NO_CLASS) ||
           (group == CBS_DATA_CODING_UDH)) {
    guint16 charset = pdu[offset] & CBS_DATA_CODING_GENERAL_CHARSET_MASK;
    /* We don't handle compression or 8 bit data */
    if (charset == CBS_DATA_CODING_GENERAL_GSM7)
        cbm_part->encoding = MM_SMS_ENCODING_GSM7;
    else if (charset == CBS_DATA_CODING_GENERAL_UCS2)
        cbm_part->encoding = MM_SMS_ENCODING_UCS2;
} else {
    g_set_error (error, ...);   /* unrecognised group */
    return NULL;
}

The charset mask covers two bits, so there are four possible charset values: GSM7 (0x00), 8-bit (0x04), UCS2 (0x08), and reserved (0x0C). The branch sets encoding for two of them. The comment even says the quiet part out loud, “We don’t handle compression or 8 bit data,” but not handling it here means the if/else if simply falls off the end. When the charset is 8-bit or reserved, encoding is never assigned, and it keeps the UNKNOWN value that g_slice_new0 gave it. There is no else on this inner branch to catch that.

Take DCS 0x44. The top bits 0b0100 are the general coding group with no message class. The charset bits 0b01 are 8-bit data. That is a legal DCS per 3GPP TS 23.038 section 5, not a malformed byte. It walks straight through this branch and leaves encoding at UNKNOWN.

The assert

A bit further down, the parser switches on the encoding to work out the message length:

switch (cbm_part->encoding) {
case MM_SMS_ENCODING_GSM7:
    len = ((pdu_len - offset) * 8) / 7;
    break;
case MM_SMS_ENCODING_UCS2:
    len = pdu_len - offset;
    if (len % 2)
        len--;
    break;
case MM_SMS_ENCODING_8BIT:
case MM_SMS_ENCODING_UNKNOWN:
default:
    g_assert_not_reached ();
}

There it is. UNKNOWN and 8BIT share the default case, and the default case is g_assert_not_reached(). On a value the network chose, the daemon aborts:

ERROR:../src/mm-cbm-part.c:286:mm_cbm_part_new_from_binary_pdu: code should not be reached
Bail out! ERROR:../src/mm-cbm-part.c:286:mm_cbm_part_new_from_binary_pdu: code should not be reached

Process exits with 134, which is 128 plus SIGABRT.

Every crashing DCS

Rather than reason about which values reach the assert, I ran all 256 of them through the real parser. Because the abort kills the process, each value ran in its own fork(), and the parent recorded whether the child exited normally, returned a clean GError, or died on a signal.

parsed OK       : 73
clean GError    : 159
abort (SIGABRT) : 24

The 24 DCS bytes that reach the assert:

0x44 0x45 0x46 0x47   0x4C 0x4D 0x4E 0x4F
0x54 0x55 0x56 0x57   0x5C 0x5D 0x5E 0x5F
0x94 0x95 0x96 0x97   0x9C 0x9D 0x9E 0x9F

They are exactly the general-class, general-no-class, and UDH groups (0x40, 0x50, 0x90) crossed with the 8-bit and reserved charset bits (0x04, 0x0C). Everything lines up with the source.

The fix

The change is small, which is usual for this class of bug. Instead of only catching an unrecognised coding group, reject any encoding that came out of the whole block still UNKNOWN:

             cbm_part->encoding = MM_SMS_ENCODING_GSM7;
         else if (charset == CBS_DATA_CODING_GENERAL_UCS2)
             cbm_part->encoding = MM_SMS_ENCODING_UCS2;
-    } else {
+    }
+
+    if (cbm_part->encoding == MM_SMS_ENCODING_UNKNOWN) {
         g_set_error (error,
                      MM_CORE_ERROR,
                      MM_CORE_ERROR_FAILED,
                      "Unhandled cbm message encoding: 0x%02x",
                      pdu[offset]);
         return NULL;
     }

The old else only ran when the group itself was unrecognised. Turning it into an unconditional post-check catches both that case and the new one, the general or UDH group with an 8-bit or reserved charset, because both leave the field at UNKNOWN. By the time control reaches the encoding switch, encoding is guaranteed to be GSM7 or UCS2, so the g_assert_not_reached() becomes a real internal invariant instead of something the network can trip. The message is dropped rather than decoded, which matches the parser’s own comment about not handling 8-bit data.

What to take from it

The bug is not the missing else. That is where it surfaced. The bug is a g_slice_new0 default that happens to be a real enum value with meaning, combined with a parser that treats “I set the encoding” and “I did not touch the encoding” as the same state until an assert forces the question. A zero-initialised default is only safe if every branch either overwrites it or is prepared to see it. Here one branch did neither, and the place that finally noticed was an assert that assumed it could never be wrong.

And g_assert_not_reached() on attacker-influenced input is a crash you wrote for yourself. It reads like defensive programming, but on a boundary that parses network data it is an abort with extra steps. A parser running as root should turn input it does not like into an error and move on, which is what the fix does.

If you maintain a decoder like this, the audit is worth a minute: does every default: in a decode switch return an error, or does one of them abort on input you do not control?

For the longer story, including why I trust a fork-per-byte sweep over reading the code, see the blog post.