8 min read

SELinux Multi-SoC Policy Architecture: From Pitfalls to Design Contracts

Table of Contents

You’d think the hardest part of SELinux is writing .te rules. For a single-SoC product, it is. But once you’re supporting AML, RTK, and MTK simultaneously — each with its own BSP, its own HAL, its own sysfs nodes — the hard part shifts from “what to write” to “which directory does this rule belong in.”

This post covers pitfalls encountered in multi-SoC sepolicy work: silently ineffective build flags, companion CLs missed during cross-repo review, and type visibility failures caused by Treble isolation boundaries.


Fundamentals: The Three-Layer Protection Model

Before getting into architecture, let’s align on the basics. For SELinux to protect a node (file, socket, property), three layers are required — missing any one breaks the chain:

LayerWhat it doesWhereWhat happens if missing
Type declarationDefine a new security typetype sysfs_hdmi, file_type, sysfs_type;Build fail: unknown type
Label bindingBind a path to the typefile_contexts / property_contextsNode stays at default label; rules don’t take effect
Allow rulePermit a domain to operate on the typeallow in .teavc: denied
# 1. Type declaration
type sysfs_hdmi, file_type, sysfs_type;

# 2. Label binding (file_contexts)
/sys/class/hdmi/hdmi0/hpd_state  u:object_r:sysfs_hdmi:s0

# 3. Allow rule (.te)
allow system_server sysfs_hdmi:file { open read };

The consequences of missing a layer are asymmetric: a missing type declaration fails the build, but missing the other two layers results in silent runtime failure. The latter is harder to detect and more expensive to debug. Keep this “three-layer completeness” in mind — every design decision below revolves around it.


Architecture: Layered Contract Design

Splitting Directories Isn’t Architecture

The usual first instinct is to create parallel directories per SoC:

sepolicy/
├── soc/aml/
├── soc/rtk/
└── soc/mtk/

Directories created — then what? The critical questions remain unanswered:

  • sysfs_hdmi is declared in soc/aml/, but soc/rtk/ also needs the same type. Who owns the declaration?
  • Multiple SoCs all have HDMI but with different sysfs paths. Do shared allow rules get duplicated for each?
  • ifeq ($(DEVICE_SOC_AML), true) — if nobody sets that flag, what happens to the entire directory?

The split should follow visibility contracts, not SoC names:

sepolicy/
├── common/              # Tier 1: shared across SoCs
│   ├── public/          #   types exported for all vendor .te to reference
│   ├── *.te             #   shared domain allow rules
│   └── file_contexts    #   shared node labels

├── soc/                 # Tier 2: SoC-specific
│   ├── aml/
│   ├── mtk/
│   └── rtk/

└── product/             # Tier 3: product/memory variant overrides
    └── (PRODUCT_PRIVATE_SEPOLICY_DIRS)

Push Types Up, Push Allow Rules Down

WhereWhy
Types that any SoC might reference → common/public/Vendor .te can only see public types (Treble isolation); this is the only way to make them cross-SoC referenceable
SoC-specific domains + allow rules → soc/<soc>/Isolation — don’t pollute other SoCs
Shared domain allow rules → common/Don’t let each SoC duplicate them

There’s a hard Treble constraint here: vendor partition .te files can only reference types marked as public. If a type declaration lives in product-private, vendor-side .te files referencing it will fail at build time — not runtime, it simply won’t compile. So any type that will be referenced across SoCs goes into common/public/, and the visibility problem ceases to exist.


Pitfalls in Practice

Silently Ineffective Build Flags

The most lethal issue isn’t avc: denied — it’s an entire SoC’s sepolicy directory vanishing without a trace.

# ❌ Dangerous: multiple independent boolean flags
ifeq ($(DEVICE_SOC_AML), true)
  BOARD_SEPOLICY_DIRS += vendor/<platform>/sepolicy/soc/aml
endif

If no makefile ever sets DEVICE_SOC_AML, the ifeq is always false. The build won’t complain — it just silently skips the entire directory. Not a single .te rule gets loaded, the build passes, and the problem defers to runtime as a flood of avc: denied messages. You’ll assume the rules are wrong when in fact they were never loaded.

Use a single enum variable with path concatenation instead:

# device/<vendor>/<soc>/soc.mk
TARGET_SOC_FAMILY := aml   # every SoC's device makefile must set this

# shared sepolicy.mk
BOARD_SEPOLICY_DIRS += vendor/<platform>/sepolicy/common
BOARD_SEPOLICY_DIRS += vendor/<platform>/sepolicy/soc/$(TARGET_SOC_FAMILY)

$(TARGET_SOC_FAMILY) concatenated directly into the path is safer than ifeq: if the variable is unset, the path becomes soc/, and the build fails early because the directory doesn’t exist — instead of deferring to runtime. Add an assertion to surface the failure even earlier:

ifeq ($(TARGET_SOC_FAMILY),)
  $(error TARGET_SOC_FAMILY not set - check device soc.mk)
endif

The underlying philosophy is the same as handling memory variants: variants are configuration, not forks. One set of makefiles, one sepolicy tree, driven down different paths by variables — not multiple forked directories maintained independently.

Cross-Repo Three-Layer Completeness

Someone submits a CL deleting an SELinux rule, the reviewer sees the CL is internally consistent and +2’s it. But the type’s file_contexts binding lives in a different repo and also needs a companion CL for removal — nobody catches it, build breaks after merge.

This isn’t a reviewer failing at their job. It’s a gap in the process. Expecting everyone to remember “when you see SELinux rule deletion, go check the other repo” shouldn’t be a process dependency.

The design goal is to keep the three layers converged:

# ✅ SoC-specific node, all three layers in soc/aml/
soc/aml/
├── sysfs_hdmi.te          # type declaration + allow
└── file_contexts          # label binding

# ❌ Three layers scattered across locations
common/public/sysfs_hdmi.te     # type declaration
soc/aml/file_contexts           # label binding
soc/aml/system_server.te        # allow

Splitting three layers across repos/tiers is technically legal — the build system merges all BOARD_SEPOLICY_DIRS during compilation. But legal doesn’t mean maintainable. The goal is for a reviewer to look at one CL and be able to judge whether the three layers are complete. So SoC-specific nodes get all three layers in the corresponding soc/<soc>/; only truly cross-SoC shared types get promoted to common/public/.

init_daemon_domain() Missing file_contexts

When init_daemon_domain(my_service) declares an init-launched service domain, SELinux needs to know which binary maps to that domain — and that information comes from file_contexts.

# .te
init_daemon_domain(my_service)

# file_contexts (without this, domain transition won't happen)
/system/bin/my_service_binary  u:object_r:my_service_exec:s0

What if you write init_daemon_domain() but forget to label the binary in file_contexts? The binary runs under init’s domain — with all of init’s permissions. This is a real privilege escalation issue, and the build won’t say a word.

This shouldn’t rely on human review. AOSP ships sepolicy_tests and checkfc, which can detect file_contexts referencing nonexistent types, unlabeled binaries, and similar problems. Wire them into CI as a gating check.

get_prop() Redundancy

Manually expanding permissions that a macro already includes:

# ❌ Redundant: get_prop() already includes open/read/getattr
allow my_domain my_prop:file { open read getattr };
get_prop(my_domain, my_prop)

# ✅ The macro handles it
get_prop(my_domain, my_prop)

get_prop() expands to allow ... file { open read getattr map }. Writing it again manually won’t break anything, but policy bloats, and it misleads reviewers into thinking there’s a special requirement — when there isn’t. This kind of redundancy is best caught by lint.


Extension: Tier 3 for Product Variants

If the platform has memory variants (2GB / 4GB STB), there will occasionally be sepolicy differences — for instance, a memory optimization service that only exists on 2GB devices.

PRODUCT_PRIVATE_SEPOLICY_DIRS += \
    vendor/<platform>/sepolicy/product/$(MEMORY_VARIANT)

Align this with soong_config and manifest overlay mechanisms. The “variants as configuration” philosophy should run consistently from manifests and build flags all the way through to sepolicy.


Summary

DecisionPrincipleAnti-pattern
Type visibilityCross-SoC types → common/public/Public types in product-private; vendor .te won’t compile
SoC isolationSoC-specific three layers converge in soc/<soc>/Three layers scattered across repos/tiers; companion CL missed
Build flagsSingle enum variable + path concatenation + assertMultiple boolean flags; silent no-op when setter is missing
Completeness checkscheckfc / sepolicy_tests in CIRelying on human reviewers
Product variantsTier 3 via PRODUCT_PRIVATE_SEPOLICY_DIRSForking sepolicy directories and maintaining them independently
MacrosTrust the expansion; don’t duplicate manuallyAdding allow ... open read next to get_prop()

Multi-SoC sepolicy ultimately becomes a governance problem: who defines the contract, who verifies it. Directory structure is just the contract’s shell. The three things that need to be nailed down are whether type declaration visibility boundaries are clearly defined, whether three-layer completeness can be machine-verified, and whether build flag failures get caught early. Get those right, and the rest is execution.


Note: TARGET_SOC_FAMILY is an illustrative variable name — AOSP has no such standard variable. When implementing, use whatever SoC identification variable your platform’s conventions call for.