jemalloc: NULL arena deref when per-thread arena creation fails under mlockall(MCL_FUTURE) + RLIMIT_MEMLOCK

Chanda Mendon <[email protected]> Wed, 15 Jul 2026 20:19:05 +0000
Newsgroups gmane.os.freebsd.devel.hackers
Message-ID <DS0PR05MB100164E90138A1F355310DCD1B4F82@DS0PR05MB10016.namprd05.prod.outlook.com>
  Hi,


  We root-caused a deterministic ntpd crash on a FreeBSD 15-based system

  to a NULL-pointer dereference inside jemalloc that occurs whenever

  creation of a per-thread arena fails.  We have two candidate fixes and

  would appreciate review.  (Thanks to Justin Hibbits for suggesting this

  list.)


  THE BUG


  Environment that exposes it:

    - the process sets RLIMIT_MEMLOCK to 32MB, then calls

      mlockall(MCL_CURRENT|MCL_FUTURE).  ntpd does this when built with

      DFLT_RLIMIT_MEMLOCK=32; FreeBSD base builds with -1 so stock ntpd

      is unaffected, our vendor build used 32.

    - wired memory sits close to the limit (~30MB of 32MB here)

    - a new thread performs its first malloc


  Sequence, all in contrib/jemalloc:


    1. First malloc in a thread with no bound arena ->

       arena_choose_impl() -> arena_choose_hard()   [src/jemalloc.c]


    2. All initialized arenas are busy and a free slot exists, so

       arena_choose_hard() calls arena_init_locked().  The new arena's

       base mmap must be wired immediately (MCL_FUTURE) and would exceed

       RLIMIT_MEMLOCK, so mmap fails -> arena_init_locked() returns NULL

       -> arena_choose_hard() returns NULL.


    3. In arena_choose_impl()

       [include/jemalloc/internal/jemalloc_internal_inlines_b.h]:


           ret = arena_choose_hard(tsd, internal);

           assert(ret);                    <- compiled out in production

           if (tcache_available(tsd)) {

               ...

               tcache_arena_associate(tsdn, tcache, ret);  <- ret == NULL


    4. tcache_arena_associate() does


           malloc_mutex_lock(tsdn, &arena->tcache_ql_mtx);


       With arena == NULL this computes offsetof(arena_t, tcache_ql_mtx)

       plus the lock offset (0x2818 in our build) and faults.


  FIX CANDIDATE 1 - fail the allocation (arena_choose_impl):


      ret = arena_choose_hard(tsd, internal);

      if (unlikely(ret == NULL)) {

          return NULL;            /* malloc fails with ENOMEM */

      }


  Smallest diff.  However, the allocation fails despite free space in

  the already-initialized arenas, and the thread retries arena creation

  on every subsequent malloc.


  FIX CANDIDATE 2 - fall back to the least-loaded existing arena

  (arena_choose_hard):


  The selection scan leaves in choose[j] the index of the initialized

  arena with the fewest bound threads.  The creation path overwrites it

  with the free-slot index, so save it first and fall back on failure:


      unsigned saved_choose = choose[j];

      /* Initialize a new arena. */

      choose[j] = first_null;

      arena = arena_init_locked(...);

      if (arena == NULL) {

          /*

           * Creation failed (e.g. mmap ENOMEM under

           * mlockall(MCL_FUTURE) + RLIMIT_MEMLOCK).  Fall back to

           * the least-loaded existing arena rather than returning

           * NULL, which the caller dereferences.

           */

          choose[j] = saved_choose;

          if (!!j == internal)

              ret = arena_get(tsd_tsdn(tsd), choose[j], false);

      } else {

          is_new_arena[j] = true;

          if (!!j == internal)

              ret = arena;

      }

      ...

      arena_bind(tsd, choose[j], !!j);


  Arena 0 is guaranteed initialized (asserted just above the scan), so

  the fallback cannot itself be NULL.  The thread permanently shares an

  arena, but allocation keeps working; if the shared arena can't grow

  later, the normal extent-allocation failure path makes malloc return

  NULL gracefully.


  QUESTIONS


  1. Any correctness issues with either fix, particularly fix 2's

     binding of the thread to the fallback arena?

  2. Which fix is preferable for FreeBSD?

  3. Should this go to upstream jemalloc (github.com/jemalloc/jemalloc<http://github.com/jemalloc/jemalloc>)

     as well?  The assert-only NULL guard in arena_choose_impl() is

     upstream code, not a FreeBSD-local change.


  Thanks,

  Chanda



Juniper Business Use Only