malloc(0): Table for zero please!
Table of Contents
Authors: Seung Kang and Stephen Fox
The malloc function allocates a chunk of memory in C programs for storing variables and complex data structures.
malloc takes a single input: the size of the chunk to allocate in bytes. But what happens when you ask for zero bytes of memory?
My partner Stephen and I assumed malloc(0) would simply fail. A video by Billy Ellis, an iOS security researcher, demonstrated that it is not the case. He explained that on macOS/iOS, malloc(0) creates a 16-byte allocation. If that is the case on Apple platforms, we wondered how other allocators and operating systems may react.
Wait, that works?
Billy’s video, “Why can’t your iPhone render this image?”, covers CVE-2026-28990, a bug in the EXR image parser code in Apple’s ImageIO library. When parsing an EXR image, the ImageIO code computes an allocation size to pass to malloc by multiplying together an EXR image’s channel count, width, and height. The result of that calculation is stored in a 32-bit integer. Ellis was able to craft an image whose dimensions overflow that calculation to 0, resulting in malloc(0) being executed. The overflow results in the image’s actual size becoming decoupled from the memory required to store it.
Since calling malloc(0) returns a pointer to a 16-byte chunk rather than failing, the decoder logic continues and overflows the malloc allocation when it writes the much larger image data to it. The heap overflow could allow an attacker to manipulate the program’s state and eventually run their own code. Ellis frames it as a potential first step toward a full remote code execution chain, similar to the exploits used by the Pegasus spyware.
Stephen and I wondered why malloc(0) does not just fail to begin with. That got us wondering how other operating systems handle malloc(0). It also raised another question: did the size specifically need to be 0 for the bug to work? Billy answered that question directly, later clarifying in a comment on his video:
It can be anything, really, as long as it is a smaller buffer than the copy operation expects… you’d likely explore different buffer sizes to target different objects on the heap, since these are grouped by size.
So malloc(0) was not special. Any undersized allocation would do. Picking a size just lets the attacker target specific groups of objects since iOS’ heap is separated into several “zones” based on chunk size.
We still wanted to see how other operating systems’ allocators treat malloc(0) in practice. That meant creating a small test program and running it on a variety of platforms.
Test program
To test the behavior of malloc(0) across different operating systems, we created a small C program that runs on multiple platforms. The program makes alternating calls to malloc(0) and malloc(10), which helps us better understand whether the chunks are allocated near “normal” chunks. It then attempts to determine the size of the chunk allocated by malloc (assuming the OS supports it) and writes some data to it. The test program’s source code, notes, and build automation can be found here.
For each OS, we used its allocator’s native API to query the usable size of the allocation returned by malloc, _msize() on Windows via its CRT, malloc_usable_size(), or malloc_size() elsewhere. OpenBSD was the one exception with no native API.
We used sourcehut’s CI system and our own local machines to run the test program on seven platforms.
Test setup
sourcehut is a lightweight software forge whose build service, builds.sr.ht, runs YAML-defined build pipelines against a wide range of operating systems. We chose sourcehut as a test platform because it lets us run a test program across several BSD and Linux distributions without provisioning and maintaining our own virtual machines for each one. Here is what we ended up testing on sourcehut:
- FreeBSD
- NetBSD
- OpenBSD
- Alpine Linux
- Ubuntu
For macOS and Windows, testing happened on devices already on hand.
Running the test as a build on sourcehut required a build manifest specifying the operating system and version. We created a separate manifest for each OS, referencing its latest supported version, and ran the same steps to build and run the C program. The CI automation also used each operating system’s tools and interfaces to capture information about the allocations so we could better understand where they appeared in memory.
For example, here is the manifest we used for the FreeBSD build:
# this is the builds.sr.ht manifest
image: freebsd/15.x
sources:
- https://github.com/SeungKang/mall0c
tasks:
- build: |
cd mall0c
clang main.c
./a.out | ( read pid && procstat vm "$pid"; kill "$pid" )Results
Every platform we tested returns a valid pointer for malloc(0), though only OpenBSD refuses to let you write to it by killing the process through a segmentation fault signal. Usable size and allocation placement both vary from OS to OS.
| OS | Allocator | malloc(0) returns valid ptr? | can write to malloc(0) ptr? | Usable size (bytes) | Method used to get usable size |
|---|---|---|---|---|---|
| Alpine 3.23 | musl libc (mallocng) | yes | yes | 0 |
malloc_usable_size() from <malloc.h> |
| Ubuntu 25.04 | glibc malloc (ptmalloc2) | yes | yes | 24 |
malloc_usable_size() from <malloc.h> |
| OpenBSD 7.8 | OpenBSD malloc(3) | yes | no (segfault) | 0 |
No native API |
| FreeBSD 15.x | jemalloc | yes | yes | 8 |
malloc_usable_size() from <malloc_np.h> |
| NetBSD 10.x | jemalloc | yes | yes | 8 |
malloc_usable_size() from <malloc.h>, linked with -ljemalloc |
| Windows 11 | Windows Heap (ucrt/NT Heap) | yes | yes | 1 |
_msize() from the CRT |
| macOS Tahoe 26.6.2 | libmalloc | yes | yes | 16 |
malloc_size() from <malloc/malloc.h> |
Chunk sizes
By “usable size,” we mean the number of bytes that an allocator’s own bookkeeping considers to back a given pointer. That is whatever its native size-query function has reported back. That number is not the same thing as the maximum number of bytes you can actually get away with writing. Allocators commonly round a request up to some internal minimum or alignment boundary. The memory just past that boundary is often still mapped and writable. Writing into it usually just quietly lands somewhere the allocator does not expect, corrupting whatever ends up there later.
On every platform except Windows, the allocator rounds a request up to a minimum chunk size, and “usable size” reports the actual, rounded-up block. That is why a malloc(10) request returns 16 usable bytes on macOS and 24 on Ubuntu. Windows’ _msize() does not work that way. It just echoes the size you requested: 1 for malloc(0) (since MSVC’s CRT treats a zero-byte request as a one-byte request internally) and exactly 10 for malloc(10). That holds regardless of how much space MSVC actually reserved underneath. So on Windows, “usable size” is really just the requested size in disguise, not a measurement of the real block.
OpenBSD’s own numbers make the usable-size-versus-writable-size gap explicit in the other direction. Its malloc(0) reports a usable size of 0 and faults on the very first byte. A malloc(1) pointer from that same allocator, though, turned out to have at least 64 bytes of silently writable space past it, a boundary its usable-size probe never reports at all. The number an allocator hands you is a promise about bookkeeping, not a fence around your writes.
Chunk placement
Looking at where allocations actually land, Alpine, Ubuntu, macOS, FreeBSD, NetBSD, and Windows all group allocations by size in some way. That means either packing the same-size chunks sequentially into a shared region or splitting them across separate pages by size class. On macOS, for example, our four alternating malloc(10)/malloc(0) calls landed exactly 16 bytes apart from each other in sequence (0x6000019bc030, 0x6000019bc040, 0x6000019bc050, 0x6000019bc060). All four were packed into a single shared region, regardless of the requested size. FreeBSD and NetBSD are split by size instead. The two malloc(10) pointers landed 16 bytes apart from each other, and the two malloc(0) pointers landed 8 bytes apart from each other. But the two pairs sat on entirely separate pages, roughly 4KB apart.
OpenBSD’s allocations, by contrast, showed no discernible grouping at all. The same four calls landed at 0x3643fce8a40, 0x3643fccdc10, 0x3643fcc6ee0, and 0x3643fcd8830. They landed tens of thousands of bytes apart, with no consistent pattern in size or call order. OpenBSD’s own malloc.3-man page documents this as a deliberate design. It notes that malloc has returned randomized page addresses since OpenBSD 3.8, with additional randomization introduced in OpenBSD 4.4.
The three placement patterns, drawn out:
macOS — one shared region, every chunk 16 bytes apart,
regardless of the size asked for
0x6000019bc030 +------------+
| malloc(10) |
0x6000019bc040 +------------+
| malloc(0) |
0x6000019bc050 +------------+
| malloc(10) |
0x6000019bc060 +------------+
| malloc(0) |
+------------+
------------------------------------------------------
FreeBSD / NetBSD — split by size class onto separate pages
page A +------------+
| malloc(10) |
+------------+ 16 bytes apart
| malloc(10) |
+------------+
:
: ~4 KB
:
page B +-----------+
| malloc(0) |
+-----------+ 8 bytes apart
| malloc(0) |
+-----------+
------------------------------------------------------
OpenBSD — randomized, no grouping by size or by call order
0x3643fcc6ee0 | malloc(10) (3rd call)
: ~27 KB
0x3643fccdc10 | malloc(0) (2nd call)
: ~42 KB
0x3643fcd8830 | malloc(0) (4th call)
: ~65 KB
0x3643fce8a40 | malloc(10) (1st call)OpenBSD’s decision to explicitly disallow writes to a zero-size allocation felt sensible given its focus on preventing security bugs. That said, some of OpenBSD’s programming interfaces take an even more aggressive stance against misuse, such as pledge, which kills the calling process if it does something unexpected. So it was a bit surprising to us that malloc(0) does not immediately fail or just kill the calling process, given how “incorrect” asking for a zero-size chunk appeared.
In the last five years, Apple has taken a much more proactive approach to security. So we were a bit surprised to see Apple’s malloc allow this behavior when it felt incorrect to us. In general, we still wondered why this behavior was allowed in the first place and whether there was some historical precedent for it.
History of UNIX malloc
Modern operating systems are often derived from or heavily inspired by UNIX. We looked at the UNIX malloc implementation to see whether the authors had any opinions on allocating a zero-sized memory chunk.
Lions’ Commentary on UNIX 6th Edition
Published in 1976, John Lions’ book documents one of the earliest implementations of the malloc function found in the Unix operating system. We had a physical copy on hand, which was originally recommended to Stephen by a colleague. Sheet 25, line 2522 covers the original malloc source, which is seen below:
/* Allocate size units from the given
* map. Return the base of the allocated
* space.
* Algorithm is first fit.
*/
malloc(mp, size)
struct map *mp;
{
register int a;
register struct map *bp;
for (bp = mp; bp->m_size; bp++) {
if (bp->m_size >= size) {
a = bp->m_addr;
bp->m_addr =+ size;
if ((bp->m_size =- size) == 0)
do {
bp++;
(bp-1)->m_addr = bp->m_addr;
} while((bp-1)->m_size = bp->m_size);
return(a);
}
}
return(0);
}
The historical implementation of Unix shares roughly the same function signature as contemporary malloc, except that it takes an array of memory chunks as an input rather than relying on global state hidden from the user. Of note, a return value of 0 indicates that the function failed in both Unix’s malloc and modern malloc.
In section 5-2 of the commentary section of the book, Lions explains this convention directly:
A value of Zero returned means ’no luck.’ This is based on the assumption that no valid area can ever begin at location zero.
Lions summarizes the code as consisting of two code paths:
(a) the end of the list of available resources is encountered; or
(b) An area large enough to honor the current request is found;
A zero-size request is not discussed in the book. We turned to Apple’s malloc implementation for more hints.
Apple’s decision and the POSIX standard
Apple’s malloc implementation is open-source and available in the libmalloc library. After reviewing libmalloc’s code for clues about Apple’s choice to allow a zero-size malloc, we found a code comment that provided some insight:
// SUSv3: "If size is 0 and ptr is not a null pointer, the object
// pointed to is freed. If the space cannot be allocated, the object
// shall remain unchanged." Also "If size is 0, either a null pointer
// or a unique pointer that can be successfully passed to free() shall
// be returned." We choose to allocate a minimum size object by calling
// malloc_zone_malloc with zero size, which matches "If ptr is a null
// pointer, realloc() shall be equivalent to malloc() for the specified
// size." So we only free the original memory if the allocation succeeds. The latter part of the comment (“[w]e choose to allocate a minimum size object”) essentially fully articulates Apple’s thought process. The comment’s “SUSv3” is a reference to the third version of the Single Unix Specification. After some searching around, we found old Apple marketing material for the 2007-era OS-X Leopard that states:
Leopard is now an Open Brand UNIX 03 Registered Product, conforming to the SUSv3 and POSIX 1003.1 specifications for the C API, Shell Utilities, and Threads.
POSIX (Portable Operating System 9) is a standard for implementing operating system building blocks and programming interfaces, including malloc. There have been several revisions to POSIX over the years, and Apple chose to target the 2004 revision, which was the most recent revision at the time OS-X Leopard was developed. Looking at the introduction for the 2004 revision of POSIX, it states that SUSv3 and the 2004 POSIX revision are the same document: “This set of specifications forms the core of the Single UNIX Specification, Version 3”.
The reason any of this matters is because POSIX’s opinion on how malloc(0) behaves has changed over the years. Getting back to that code comment from earlier, part of the comment is copied verbatim from the 2004 POSIX malloc specification:
If size is 0, either a null pointer or a unique pointer that can be successfully passed to free() shall be returned.
In the most recent revision of POSIX (2024), we can see that the specification differs quite a bit and reflects what OpenBSD does:
[I]f size is 0, the application shall ensure that the pointer is not used to access an object.
It appears that Apple elected to implement the behavior from the 2004 revision of POSIX and has not changed it since. We imagine that continuing to conform to this behavior over the years was an easy choice given all the assumptions various applications have made about this behavior. It is easy to imagine the nasty rippling effect of targeting newer POSIX revisions in a multiplatform operating system that has to support more than just desktops and laptops.
Still, the 2004 revision provided the option to fail by returning zero. So why not just return zero to indicate a failure?
Signaling a malloc failure is actually complicated
Rich Felker, the lead developer of musl libc, wrote in detail about why malloc returning zero on a zero-size request is problematic in a 2013 musl mailing list thread. The first challenge he described was that programs will often replace malloc with a custom implementation that returns a non-zero value when the system’s malloc returns zero, thus not honoring the failure from libc.
Rich also pointed out a more fundamental problem: that the behavior of malloc is closely tied to the realloc function according to POSIX and the C standards. The Apple code comment from earlier appeared in Apple’s realloc implementation, and it was describing the complexity of the relationship between realloc and malloc. It appears that Apple opted to satisfy realloc’s requirements by always allowing malloc to succeed and return a pointer to an allocated chunk, even if a zero-size request is made.
In his mailing list post, Rich also linked to a discussion regarding how the 2004 and 2008 revisions of POSIX had failed to keep up to date with the C99 standard’s realloc behavior.
There are two additional issues about this behavior for malloc (374 and 526). At some point, the 2008 revision of POSIX and the subsequent year revisions were updated to say something that more closely resembles what we see in the latest (2024) revision:
If size is 0, either:
- A null pointer shall be returned and errno may be set to an implementation-defined value, or
- A pointer to the allocated space shall be returned. The application shall ensure that the pointer is not used to access an object.
Issue 374 appears to document where the POSIX 2024 language was discussed and enacted, which is the more security-conscious behavior (“the application shall ensure that the pointer is not used to access an object”).
Final thoughts
Billy Ellis’ explanation of an Apple image parser bug pointed out unexpected behavior in malloc that neither of us had considered before. The zero-size malloc behavior is probably not entirely useful to a hacker beyond a very bespoke exploitation scenario. That said, hackers tend to face weird constraints, so maybe this behavior will be useful one day :)
Across the operating systems we tested, all seven permit a zero-size malloc. Six of them (Alpine, Ubuntu, FreeBSD, NetBSD, Windows, macOS) go further and let you actually write to it. OpenBSD does not. Instead, it catches misuse of a zero-byte allocation after malloc has been executed. That behavior appears to match the malloc specification for later revisions of POSIX.
Apple’s malloc implementation predates the modern POSIX revisions. Perhaps Apple could update libmalloc to match OpenBSD’s behavior. Doing so will likely have a negative impact on existing applications that depend on the existing behavior of realloc and malloc. Going forward, it will be interesting to see if other libc implementations adopt the POSIX and OpenBSD behavior.
Thank you for reading!