CVE-2025-43505: A Signed Integer Overflow in gm4’s Frozen State Loader
Apple Security Advisory: Xcode 26.1
Introduction
This is a signed integer overflow leading to heap corruption I found in gm4’s src/freeze.c, Apple’s build of GNU M4, the macro processor bundled with its developer toolchain. The source is mirrored at apple-oss-distributions, so the loader that runs on a Mac is available to read directly.
GNU M4 lets you serialize the entire state of a macro processing session to a “frozen state” file, typically ending in .m4f. You dump the state with -F and reload it later with -R, which saves re-parsing a large macro library on every run. The format is a simple line-oriented text protocol: each directive is a single letter followed by numeric length fields and comma separators, with the payload data on the following lines. A T directive, for instance, defines a user macro by giving the length of its name and the length of its expansion text, then reading exactly that many bytes for each. The bug is that gm4 treats those numeric length fields as trusted and parses them into a signed int with no overflow check, so once a length exceeds INT_MAX the buffer-reallocation logic and the read that follows disagree about how big the value is.
The Vulnerability
The loading logic lives in reload_frozen_state(). The buffers and their bookkeeping counters are declared as plain signed integers:
int allocated[2];
int number[2];
The numeric length fields are parsed by the GET_NUMBER macro, which accumulates decimal digits with no bounds checking whatsoever:
#define GET_NUMBER(Number) \
do \
{ \
(Number) = 0; \
while (isdigit (character)) \
{ \
(Number) = 10 * (Number) + character - '0'; \
GET_CHARACTER; \
} \
} \
while (0)
Nothing caps the accumulator. Feed it enough digits and 10 * (Number) + ... passes INT_MAX and wraps into negative territory. Signed integer overflow is undefined behavior in C, but in practice on the platforms gm4 runs on it wraps two’s-complement style, so 2147483648 becomes -2147483648.
The check that decides whether the destination buffer is big enough is performed in signed arithmetic:
if (number[0] + 1 > allocated[0])
{
free (string[0]);
allocated[0] = number[0] + 1;
string[0] = xmalloc ((size_t) allocated[0]);
}
When number[0] is a large negative value, number[0] + 1 is still negative, and negative is not greater than the current allocated[0]. So the branch is skipped entirely and the buffer is never grown. As far as this check is concerned, we asked for a tiny (in fact, nonsensical) amount of space, so the existing allocation is deemed plenty.
Root Cause
The bug is the asymmetry between how that number is read when deciding how much room to make versus how much to write. A few lines after the reallocation check, the actual read is performed, and here the same number[0] is cast to size_t:
if (number[0] > 0)
if (!fread (string[0], (size_t) number[0], 1, file))
M4ERROR ((EXIT_FAILURE, 0, "premature end of frozen file"));
The reallocation check is signed, so a wrapped negative value looks “small” and the buffer is never grown. The fread size argument, (size_t) number[0], reinterprets that same negative int as an enormous unsigned value: on a 64-bit platform a negative 32-bit int sign-extends and reinterprets into something on the order of 0xFFFFFFFF80000000. fread reads element by element and writes whatever bytes the file actually contains, so it streams the payload into string[0], a buffer that was never resized to hold it, writing past the end of the heap allocation. The signed check treats the value as small; the unsigned read treats it as enormous. That asymmetry is the bug.
Reproduction
Conceptually, the trigger is a frozen state file with a version header and a T directive whose name-length field is set to exactly INT_MAX + 1 (2147483648), followed by a payload. GET_NUMBER parses the length into the signed int number[0] and wraps it to -2147483648. The negative value skips the reallocation, and the subsequent fread((size_t) number[0], ...) writes the payload past the end of the under-sized string[0] buffer, corrupting whatever adjacent chunk metadata or allocations happen to live there.
Upstream GNU M4 hardened exactly this path, widening the counters to size_t and rewriting GET_NUMBER to use checked multiply and add (ckd_mul / ckd_add) so any overflow is detected and the file rejected before a wrapped value is ever stored:
if (ckd_mul(&(Number), 10, (Number)) ||
ckd_add(&(Number), (Number), character - '0'))
OVERFLOW;
Any workflow that reloads a .m4f it did not itself produce is exposed: the frozen file is data, but gm4 treats its length fields as if they came from a trusted source.