Properties & the control plane

Trace shared property reads, authorized writes, persistence, and service-control messages.

9 min readChapter 20 of 50Intermediate
In this chapter

An Android property looks like a string in a dictionary. Its implementation is more interesting: cheap shared-memory reads, a privileged write service, typed and labeled names, boot-time initialization, optional persistence, and special control messages. Understanding those pieces explains why getprop can succeed while setprop fails—and why changing a value is not the same as changing a security mechanism.

Before you start#

Read init and Android services, Android's security model, and Linux files and processes. The source baseline is AOSP android-14.0.0_r1. Required practice is source-only; optional device commands are read-only. Do not change boot, USB, power, debugging, or persistent properties on a personal device.

Learning goals#

Trace a property write from client to permission/type checks and state notification; explain property contexts, write-once ro.*, persistent persist.*, and special ctl.* messages; distinguish process state from readiness; and diagnose property failures without assuming root is a universal fix.

Why properties exist#

Boot components need small configuration/state values before a full Java framework or database is available. Native daemons also need a low-overhead way to discover selected settings. Android's property infrastructure supplies this shared vocabulary across components written in different languages and started at different times.

Properties are not environment variables. An environment variable is inherited through process creation and belongs to that process environment. A property is part of Android's system property infrastructure, subject to platform access rules. Setting a host environment variable named ro.debuggable does not change a target's property. A Java system property is another separate mechanism; similar terminology does not make the stores interchangeable.

Properties are also a poor secret store or general-purpose database: names/values have format constraints, readers need appropriate access, and notification/persistence rules are specialized. A property can be a supported cross-partition interface, which means its name, type, owner, and meaning deserve compatibility review. See AOSP property guidance.

Separate reads from writes#

Ordinary reads use the libc property interface and mapped property areas rather than asking init through a new RPC for every lookup. Serial/version coordination lets readers observe updates through the intended API; direct unsynchronized byte access is not a replacement. Access to the relevant property area is constrained by labels and policy. Readable does not mean writable.

Writes go through the property service, which has the authority to validate and update the shared state. The client does not obtain permission merely by knowing a name or by mapping a readable area. The Android 14 implementation is in system/core/init/property_service.cpp; libc's side is in bionic/libc/system_properties/.

The property write boundary
  1. Client request and peer identity

    The service obtains caller credentials/context; a supplied string is not authority.

  2. Name, label, permission, and type

    Validate the interface and whether this domain may set its value.

  3. Update or special control path

    Write-once properties, persistent updates, and ctl messages have distinct handling.

  4. Notify interested components

    A value update is not proof that dependent work completed or an endpoint is ready.

Source map. Android 14 property-service stages, not a live write. ctl messages diverge into control handling; persistence applies only under its stated conditions.

Follow one write through the implementation#

Imagine an authorized vendor daemon requests a fictional boolean property vendor.demo.ready=true. This is an invented interface, not a property to set on your device.

  1. Establish the peer. The property service accepts the request and obtains peer credentials/security context. A UID supplied inside an arbitrary value is not the caller's authority.
  2. Validate the name. CheckPermissions() rejects illegal property names before treating them as configuration.
  3. Find the target label/type. Property metadata maps the name to a target SELinux context and a value type. A name prefix is not itself an allow rule.
  4. Check mandatory access. CheckMacPerms() evaluates the caller against the target's property-service access. UID 0 does not make the domain irrelevant.
  5. Check value type. CheckType() can reject a value that does not match the declared boolean, integer, enum, or other supported type.
  6. Apply special handling or update. HandlePropertySet() distinguishes control messages and special properties from ordinary PropertySet() updates.
  7. Notify and possibly persist. Successful ordinary updates notify the control plane; eligible persistent writes have additional storage handling.

These are actual named functions in the pinned source; the example name/value are fictional. An access-denied result means the write did not reach the successful update merely because a shell command was executed. A type error and a policy denial need different repairs.

Property contexts: labels are part of the interface

The platform assembles property-context metadata from the supported system/vendor configuration. It maps names or prefixes to SELinux types and, where specified, value types. Policy then controls which domains can set or read those properties. A newly invented name can match an unintended broader prefix or fail to have the expected access; check the actual assembled metadata and policy, not only one source file.

Why not allow every daemon to write every setting? Some properties influence service startup, debugging, shutdown, or other security-sensitive behavior. A compromised low-privilege component must not gain control over those decisions by choosing the right string. Namespace/ownership conventions also help prevent independent teams from accidentally defining conflicting interfaces.

Three prefixes, three different mechanisms#

ro means write once in the ordinary service

In PropertySet(), an existing property with a name starting ro. is rejected as read-only. The source comment calls it write-once. Boot initialization can set the original value; an ordinary later write cannot overwrite it through that path. This is more precise than “the text comes from an immutable file.”

The write-once rule is one control, not hardware attestation. A modified booted implementation can establish different values or alter the mechanism. A property reporting a verified state is an observation from the running system, not independently authenticated proof to a remote verifier. Conversely, editing a displayed value does not rebuild a production daemon or add kernel capabilities.

persist means an additional persistence path

The pinned PropertySet() waits until persistent properties have been loaded before writing eligible socket-originated persist.* updates to disk. Its comment explains why: writing defaults too early could overwrite stored values. The implementation can use a persistence worker and asynchronous response handling.

This is why “property is visible now,” “persistence operation completed,” and “the value will be restored on the next boot” are distinct assertions. Test the actual supported lifecycle before promising durability. A RAM-only model of a property table cannot establish reboot behavior. Nor should a reader assume every persist.* value is safe to change just because it survives reboot.

ctl means a control request, not an ordinary stored setting

HandlePropertySet() routes ctl.* names to SendControlMessage() after permission checks. For ctl.start, ctl.stop, and ctl.restart, the value names an init service. The source checks legacy and action/service-specific permission forms. Treating the message as a generic dictionary insertion misses the privileged operation entirely.

Conceptually, a supported caller can ask init to start a service. That still means requested process start, not “the service is ready.” The separate init.svc.<name> property reports init's process lifecycle state. A running process may still be loading configuration, failing Binder registration, or waiting for hardware. Readiness belongs to the service's own protocol.

Notifications do not make a distributed transaction#

An init on property:name=value action can be queued when its conditions match. Its commands run according to init's action model. That does not make a property update, another daemon's work, and a third client's observation one atomic transaction. A boolean “ready” flag can become stale if the owner crashes or changes generations.

Worked race: daemon D publishes vendor.demo.ready=true, then crashes; client C sees the value and sends a request to an endpoint that no longer exists. The property was once accurate, but it did not keep D alive. A robust contract may need endpoint discovery/death handling, a generation identifier, or explicit readiness/error behavior. Which solution is appropriate depends on the actual interface; adding random sleeps is not synchronization.

Practice#

First trace handle_property_set_fd → HandlePropertySet → CheckPermissions → PropertySet in the pinned source. Record where caller context enters and where the ctl.* path diverges. Then classify this synthetic packet:

ResultNarrow interpretationNext evidence
Property readable; write deniedRead and write authority differCaller domain, property label, relevant policy/denial
Write rejected for typeA declared-value constraint failedIntended type and actual supplied value
Existing ro.* rejectedWrite-once branch rejected an overwriteInitialization source and actual purpose of the attempted change
Init state says running; Binder absentProcess start is not endpoint readinessRegistration/startup logs and service name/policy

Optional HOST to one disposable target, read-only:

sh
adb -s SERIAL shell getprop ro.build.type
adb -s SERIAL shell getprop init.svc.zygote
adb -s SERIAL shell getprop sys.boot_completed

Record your actual serial/build privately and label empty/denied values as such. These are different state signals, not a complete boot proof. No setprop exercise is necessary to understand the write path.

Knowledge check#

  1. Why does a readable property not imply a permitted write?
  2. What enforces the ordinary ro.* overwrite restriction?
  3. Why is ctl.start not a database entry with no side effects?
  4. Does init.svc.demo=running establish Binder readiness?

Answers#

  1. Reads and writes use different mechanisms and access decisions.
  2. The property's existing state and write-once check in PropertySet, within the running implementation.
  3. The property service routes it to init's privileged control-message handling.
  4. No. It describes process lifecycle, not successful service initialization/registration.

Checkpoint#

Ready when your trace identifies caller identity, target label/type, write-once handling, persistence conditions, control-message routing, and the readiness race. Revise if getprop, environment variables, and Java system properties are conflated, or a prefix is treated as authorization. Revisit CheckPermissions and draw the control branch separately. Live write/persistence behavior is not yet evidenced by source reading.

Sources#

A little further beneath the surface.

Check your understanding with the checkpoint. Marking a chapter as read saves a bookmark, not a proficiency assessment.