The Android Property System: Internals, Detection, and Custom-Source Anti-Detection
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes. The target sample is anonymized throughout.
The Android Property System: Internals, Detection, and Custom-Source Anti-Detection
Android properties are a key source of device and runtime information, and they’re impossible to avoid when doing device spoofing and device-fingerprint collection. Based on my understanding of the property system, this article summarizes property-based device spoofing, its detection, and anti-detection. The code is based on AOSP 10.
Part 1. How Android properties work
Property system basics
A property is a key-value pair. Property values have types — string, int, bool, and so on. Properties are also tied to a SELinux context: because properties are an important resource, they’re protected by SELinux, whose policy decides which domains can read/write which properties.
An Android property is fundamentally a form of IPC. The init process creates the /dev/__properties__ directory and the property-related files under it, then mmaps those files into its vm_area_struct with the MAP_SHARED flag. Initializing and modifying properties is reflected into those files. Other processes that need to read or modify properties also mmap those files with MAP_SHARED into their own vm_area_struct — so it’s essentially shared memory.
By design, for security, only init can initialize the property system (create files under /dev/__properties__), and only init can modify properties. Other processes must send a request to init over the /dev/socket/property_service socket to change a property, and not every process has access to that socket — ordinary app processes in the untrusted_app domain can’t reach it, so ordinary apps can’t modify properties.
Properties are roughly divided into: ro-prefixed read-only properties, persist-prefixed persistent properties, ctl-prefixed control properties, the selinux.restorecon_recursive property, and ordinary properties. They’re distinguished because init handles each type differently; I’ll detail the differences later.
For efficiency, the on-file layout of the property files is carefully designed, and for the common “fetch one property repeatedly” case Android provides a property-caching mechanism — which is also an obstacle for device spoofing.
IPC necessarily needs some synchronization mechanism; Android properties implement synchronization by wrapping the futex syscall.
The system provides the getprop tool to dump all property values. Add -Z to get the property’s SELinux context, or -T to get the value’s type.
Core property APIs
The core of the property implementation lives in bionic libc. Two important headers — <sys/system_properties.h> and <sys/_system_properties.h> — describe the API, both under bionic/libc/include/sys/. Both are includable from system code and NDK code; but including <sys/_system_properties.h> requires defining #define _REALLY_INCLUDE_SYS__SYSTEM_PROPERTIES_H_, otherwise compilation fails, because the file starts with:
#ifndef _REALLY_INCLUDE_SYS__SYSTEM_PROPERTIES_H_
#error you should #include <sys/system_properties.h> instead
#endif
You can tell the system developers didn’t want you including <sys/_system_properties.h> directly; they’d rather you use the interface in <sys/system_properties.h>.
<sys/system_properties.h>
This header’s interface mainly covers setting, iterating, and reading property values — the normal use cases. Some functions need a prop_info struct, which represents the region holding the property value; the caller doesn’t need to know the details of prop_info — just call one function to get a prop_info pointer and pass it to another to get more info.
Interface descriptions:
int __system_property_set(const char* __name, const char* __value);
Sets __name to __value. Internally it sends a request to init over the /dev/socket/property_service socket, so only a process whose SELinux policy explicitly allows it may send the request. The SELinux macro that grants property-setting is set_prop (defined in te_macros); one of its jobs is to allow the given domain to connectto the init domain’s unix_stream_socket. system_app.te specifies some properties system_app may set:
set_prop(system_app, bluetooth_audio_hal_prop)
set_prop(system_app, bluetooth_prop)
set_prop(system_app, debug_prop)
set_prop(system_app, system_prop)
This means processes in the system_app domain can both read and write properties with the u:object_r:bluetooth_audio_hal_prop:s0 context, and so on. An ordinary app can’t call this function; it gets:
type=1400 audit(0.0:901): avc: denied { write } for name="property_service" dev="tmpfs" ino=25226 scontext=u:r:untrusted_app:s0:c107,c256,c512,c768 tcontext=u:object_r:property_socket:s0 tclass=sock_file permissive=0 app=xxx.xxx.xxx
const prop_info* __system_property_find(const char* __name);
void __system_property_read_callback(const prop_info* __pi,
void (*__callback)(void* __cookie, const char* __name, const char* __value, uint32_t __serial),
void* __cookie)
These two are a pair: first __system_property_find() gets the prop_info* for the property’s value region, then __system_property_read_callback() delivers the value via callback. The value comes out through __value; __cookie is set by the caller and passed back to the callback. getprop reads a single property through these two functions. Splitting the read into two functions enables the caching mechanism: cache the prop_info pointer, then next time call __system_property_read_callback() directly. bionic/libc/private/CachedProperty.h provides this caching, improving fetch efficiency. The __serial variable identifies whether the value changed; it’s tied to the synchronization mechanism, detailed later.
int __system_property_foreach(void (*__callback)(const prop_info* __pi, void* __cookie), void* __cookie);
Iterates all properties. A process only sees properties it has permission to read; for each readable property the callback fires once, and inside the callback you call __system_property_read_callback() with the prop_info* to get the value. getprop dumps all properties this way:
void PrintAllProperties(ResultType result_type) {
std::vector<std::pair<std::string, std::string>> properties;
__system_property_foreach(
[](const prop_info* pi, void* cookie) {
__system_property_read_callback(
pi,
[](void* cookie, const char* name, const char* value, unsigned) {
auto properties =
reinterpret_cast<std::vector<std::pair<std::string, std::string>>*>(cookie);
properties->emplace_back(name, value);
},
cookie);
},
&properties);
std::sort(properties.begin(), properties.end());
bool __system_property_wait(const prop_info* __pi, uint32_t __old_serial, uint32_t* __new_serial_ptr, const struct timespec* __relative_timeout)
Waits until the serial member of prop_info* __pi is no longer __old_serial; the new value is written to __new_serial_ptr, with __relative_timeout as the wait timeout (null means wait forever). It’s useful for “wait until a property becomes a new value” — see WaitForProperty() in system/core/base/properties.cpp.
Three deprecated interfaces:
int __system_property_read(const prop_info* __pi, char* __name, char* __value);
Similar to __system_property_read_callback(), but the latter is more flexible — likely why __system_property_read was deprecated.
int __system_property_get(const char* __name, char* __value);
Fetches __name’s value in one step. Marked deprecated, but the Android framework still uses it.
const prop_info* __system_property_find_nth(unsigned __n);
Iterates all properties and returns the n-th property’s prop_info*. __system_property_foreach fully replaces it; the framework no longer uses it.
The <sys/_system_properties.h> file
These functions are basically for system use, so you generally won’t include this header:
int __system_property_set_filename(const char* __filename);
Deprecated; just returns -1.
int __system_property_area_init(void);
For init to initialize the property area. Opens files under /dev/__properties__ with O_RDWR | O_CREAT, which needs root.
uint32_t __system_property_area_serial(void);
Gets the “global serial” for the caching mechanism. __system_property_find (mentioned above) is relatively expensive; __system_property_area_serial reduces calls to it by caching the prop_info pointer. The code is in bionic/libc/private/CachedProperty.h; the “global serial” lives in /dev/__properties__/properties_serial.
int __system_property_add(const char* __name, unsigned int __name_length, const char* __value, unsigned int __value_length);
For init to add a property; needs root.
int __system_property_update(prop_info* __pi, const char* __value, unsigned int __value_length);
For init to update a property; needs root.
uint32_t __system_property_serial(const prop_info* __pi);
Returns a property area’s serial value. When a prop_info’s serial changes, the value changed, and you can re-call __system_property_read_callback to get the updated value (see Get in CachedProperty.h).
int __system_properties_init(void);
Called by ordinary processes to initialize the property area. Roughly: open and mmap the files under /dev/__properties__ — but those files are -r--r--r-- root root, so ordinary processes only get read access. Any process using the property system must call this.
uint32_t __system_property_wait_any(uint32_t __old_serial);
Deprecated; replaced by __system_property_wait.
Basic principles
With the two headers’ API covered, the basic principle can be summarized: init mainly uses <sys/_system_properties.h>. At boot it creates the files under /dev/__properties__, calls __system_property_area_init to map them into its address space, and sets some built-in properties via <sys/_system_properties.h>. Built-in properties come from the property files specified at build time, the kernel command line, and properties set explicitly in code. Then init opens the socket and waits for property-set requests from other processes.
Ordinary processes mainly use <sys/system_properties.h>. After startup they call __system_properties_init to open the files under /dev/__properties__ and map them read-only into their address space, then fetch property info through <sys/system_properties.h>.
Where are these two headers implemented? In the SystemProperties class, defined in bionic/libc/system_properties/include/system_properties/system_properties.h; the object is created in bionic/libc/bionic/system_property_api.cpp, which dispatches the interface functions to SystemProperties.
Since the property headers and definitions live in libc, any program using libc.so can use these interfaces. In fact, an ordinary process’s __system_properties_init is called by libc itself. In bionic/libc/Android.bp, libc.so links the static library libc_init_dynamic; its source libc_init_dynamic.cpp has this init statement:
__attribute__((constructor(1))) static void __libc_preinit() {
This marks the .so’s init function, called by the linker before running. It calls __libc_init_common, which eventually calls __system_properties_init.
All app processes are forked from zygote and then “specialized”; zygote’s VM startup calls __system_properties_init in libc, so all Java processes map the same /dev/__properties__ files with the same start address and size as zygote — confirmable via cat /proc/pid/maps | grep __prop | sort.
If you’ve read the linker source, you’ll notice linker_main also calls __system_properties_init, before libc does. Per __system_properties_init’s logic, calling it twice would be a problem — what’s going on?
The linker can’t depend on other shared libraries, so this __system_properties_init is expanded to __dl___system_properties_init. Analyzing the linker in IDA shows linker_main() calls __dl___system_properties_init, which initializes the .bss variable __dl__ZL17system_properties in the linker binary. So it’s different from libc’s __system_properties_init call and doesn’t interfere. The prefix logic is in linker/Android.bp:
// Insert an extra objcopy step to add prefix to symbols. This is needed to prevent gdb
// looking up symbols in the linker by mistake.
prefix_symbols: "__dl_",
Since an app process has permission to open the /dev/__properties__ files and parse them itself, it doesn’t have to use <sys/system_properties.h> — it can write its own implementation, which sidesteps many property-spoofing mechanisms that operate at the interface level.
At the system level, property-related interfaces basically all rely on <sys/system_properties.h> and <sys/_system_properties.h>.
Source analysis
init’s property-initialization stage
Let’s analyze the property system source, starting from init, which creates and initializes the property area. Looking at /dev/__properties__, there are mainly three kinds of files: property_info, properties_serial, and many files named after SELinux contexts.
property_inforepresents the mapping between all properties and their SELinux contexts: given a property name, query its context through this file. Its mapped region is represented by aContextsSerializedobject (viaandroid::properties::PropertyInfoAreaFile); each concrete context is aContextNode; theContextNode* context_nodes_array inContextsSerializedholds all context objects.properties_serialis the “global serial” (mentioned above), represented by theprop_area* serial_prop_area_member ofContextsSerialized.- The files named after SELinux contexts each aggregate properties sharing that context; each file’s mapped region is a
prop_area, and each concrete property is aprop_info.
In short: SystemProperties has a ContextsSerialized* contexts_; contexts_ has ContextNode* context_nodes_ representing each SELinux context; each ContextNode has a prop_area* pa_ member representing the mmap’d view of the corresponding property file.
The property init function is called in init’s SecondStageMain — init.cpp/SecondStageMain():
void property_init() {
mkdir("/dev/__properties__", S_IRWXU | S_IXGRP | S_IXOTH);
CreateSerializedPropertyInfo();
if (__system_property_area_init()) {
LOG(FATAL) << "Failed to initialize property area";
}
if (!property_info_area.LoadDefaultPath()) {
LOG(FATAL) << "Failed to load serialized property info file";
}
}
property_init first calls CreateSerializedPropertyInfo(), which:
- Reads these files, describing the property-to-SELinux-context mapping:
a)
/system/etc/selinux/plat_property_contextsb)/vendor/etc/selinux/vendor_property_contextsc)/product/etc/selinux/product_property_contexts, if it exists d)/odm/etc/selinux/odm_property_contexts, if it exists The content is parsed into aPropertyInfoEntryarray; each entry corresponds to a property’s name, SELinux context, type, and whether it’s anexact_matchproperty. - Calls
BuildTrieto parse thePropertyInfoEntryarray into a multi-way tree (organized like a directory). The tree is aTrieBuilder; nodes areTrieBuilderNode; the root node is theTrieBuilderNode builder_root_member ofTrieBuilder.TrieBuilderalso hasstd::set<std::string> contexts_andstd::set<std::string> types_holding all parsed SELinux contexts and property types. ThenTrieSerializer::SerializeTrie()serializes theTrieBuilderinto a string, writing it to/dev/__properties__/property_info; that file’s SELinux context isu:object_r:property_info:s0. - Calls
selinux_android_restoreconto set/dev/__properties__/property_info’s context tou:object_r:property_info:s0.
Now the details. The parsed files specify the property-to-context mapping, with three cases (example content):
dalvik.vm.boot-dex2oat-threads u:object_r:exported_dalvik_prop:s0 exact int— has anexactmarker plus a type.net.cdma u:object_r:net_radio_prop:s0— a name plus a context; the name doesn’t end with..net. u:object_r:system_prop:s0— a name plus a context; the name ends with..
Parsing puts info into nodes represented by TrieBuilderNode, with these members:
PropertyEntryBuilder property_entry_;
std::vector<TrieBuilderNode> children_;
std::vector<PropertyEntryBuilder> prefixes_;
std::vector<PropertyEntryBuilder> exact_matches_;
builder_root_ is the root; its value is root and its context is u:object_r:default_prop:s0.
Parsing dalvik.vm.boot-dex2oat-threads forms this parent-child chain (each node a TrieBuilderNode, children stored in the parent’s children_): root -> dalvik -> vm; since dalvik.vm.boot-dex2oat-threads has the exact marker, boot-dex2oat-threads becomes a PropertyEntryBuilder in vm’s exact_matches_ list.
Parsing net.cdma: root -> net; since net.cdma doesn’t end with ., it’s a prefix property; cdma becomes a PropertyEntryBuilder in net’s prefixes_ list.
Parsing net.: root -> net; since it ends with ., net becomes a PropertyEntryBuilder (context u:object_r:system_prop:s0) stored in net’s own property_entry_ member.
After parsing all files this way, the TrieBuilder tree is complete. Then TrieSerializer::SerializeTrie() serializes it. The serialized header is a PropertyInfoAreaHeader; the data following it is the serialized TrieBuilder:
struct PropertyInfoAreaHeader {
// The current version of this data as created by property service.
uint32_t current_version; // value 1
// The lowest version of libc that can properly parse this data.
uint32_t minimum_supported_version; // value 1
uint32_t size; // file size
uint32_t contexts_offset; // offset of all context strings in the file; an array of context lengths followed by the context strings
uint32_t types_offset; // offset of all type strings; an array of type lengths followed by the type strings
uint32_t root_offset; // offset of the serialized TrieBuilder in the file
};
Serializing the TrieBuilder is somewhat involved; it’s in TrieSerializer::WriteTrieNode(const TrieBuilderNode& builder_node). During serialization it sorts the children_, prefixes_, and exact_matches_ arrays to enable binary search later. We just need the deserialization to reconstruct a TrieBuilder-like structure.
After these steps we have /dev/__properties__/property_info. To look up a property’s value, first query its SELinux context through this file, then query the value through the file named after that context.
Now the deserialization of /dev/__properties__/property_info, and how to look up a property’s context by name. Android provides a helper class under system/core/property_service/libpropertyinfoparser; property_info_parser.h defines deserialization classes. PropertyInfoArea defines functions to read property_info:
void PropertyInfoArea::GetPropertyInfo(const char* property, const char** context,const char** type) const
GetPropertyInfo() returns the property’s context and type through context and type.
It mainly calls PropertyInfoArea::GetPropertyInfoIndexes to get the index of the property’s SELinux context in the context list, from which the context string follows.
GetPropertyInfoIndexes logic: start at the tree root, split the property by ., follow each segment down the child nodes, like resolving a path directory by directory. Children are sorted, so each lookup is a binary search. Continue to the leaf (e.g. for ro.hardware.egl, egl is the leaf name). In the parent node holding the leaf, match an element equal to the leaf name in exact_matches_ and return its index if found; otherwise match in prefixes_; if neither matches, fall back to the parent node’s SELinux context.
For example: net.abc has context u:object_r:system_prop:s0; net.cdma.abc has context u:object_r:net_radio_prop:s0.
Back to init’s property_init(), after CreateSerializedPropertyInfo, the next call is __system_property_area_init:
- Calls
PropertyInfoAreaFile::LoadDefaultPathtommap/dev/__properties__/property_infointo the process address space;PropertyInfoAreaFile’smmap_base_andmmap_size_hold the base and size. Through thePropertyInfoAreaFileobject you can query any property’s context. - Calls
ContextsSerialized::InitializeContextNodesto createnum_context_nodesContextNodeobjects, representing all SELinux contexts inproperty_info. They’re stored in thecontext_nodes_member — a pointer array ofContextNodeobjects. - Iterates the
ContextNodepointer array callingcontext_nodes_[i].Open; for each SELinux context it creates a file under/dev/__properties__/, e.g./dev/__properties__/u:object_r:apexd_prop:s0, sets its SELinux context viafsetxattr(the context is the filename itself), andmmaps the file into aprop_area. Theprop_areastructure:
uint32_t bytes_used_;
atomic_uint_least32_t serial_;
uint32_t magic_;
uint32_t version_;
uint32_t reserved_[28];
char data_[0];
A prop_area maps PA_SIZE = 128 * 1024 bytes; every property’s content area is this size, so ls -l /dev/__properties__/ shows all files at 128 * 1024. The created prop_area is stored in the ContextNode’s pa_ member.
4. Next opens /dev/__properties__/properties_serial, sets its context via fsetxattr, and mmaps it into a prop_area, stored in ContextsSerialized::serial_prop_area_.
Then property_info_area.LoadDefaultPath() runs — same as step 1, but on init’s static PropertyInfoAreaFile property_info_area.
That ends property_init(); the property area is initialized, all contexts are fixed, and the property files are created — but still empty.
Next init calls uint32_t (*property_set)(const std::string& name, const std::string& value) to set properties. Sources:
- Kernel command line
- Device-tree files
- Environment variables
- Directly in code, e.g.
ro.build.fingerprint,persist.sys.usb.config - System files:
/system/etc/prop.default,/system/build.prop,/vendor/default.prop, etc.
System files contribute the vast majority, generated at build time from build options and per-product mk files.
property_set eventually reaches PropertySet in property_service.cpp:
- Calls
__system_property_find()to get the property’sprop_info*. - If
prop_info*is non-null and the property starts withro, the caller is trying to change an already-setroproperty — not allowed,rois write-once. - If
prop_info*is non-null and notro-prefixed, call__system_property_updateto update it. - If
prop_info*is null, the property doesn’t exist — call__system_property_add. - If it starts with
persist, callWritePersistentPropertyto persist it. - Call
property_changed()to notify of the change.
Initially a property doesn’t exist, so __system_property_add runs:
- Calls
contexts_’sGetSerialPropAreato get theprop_area* serial_pa— the memory mapping of/dev/__properties__/properties_serial, the “global serial”. - Calls
contexts_->GetPropAreaForName(name)to get theprop_area* pa— the mapping of the file named after the property’s SELinux context.initopens the file withopen(filename, O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC | O_EXCL, 0444)(creating it if missing), thenmmap(nullptr, pa_size_, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)— read-write shared, soinitcan write the area. - Calls the
prop_area’sadd(name, namelen, value, valuelen)to set the property. - Since a new property was set, the “global serial” increments by 1, and
__futex_wakewakes processes waiting on that serial (more in the synchronization section).
The prop_area structure describes all properties sharing one SELinux context:
uint32_t bytes_used_; // total bytes allocated for the prop_area
atomic_uint_least32_t serial_; // serial; starts at 0, increments by 1 on add/update
uint32_t magic_; // 0x504f5250
uint32_t version_; // 0xfc6ed0ab
uint32_t reserved_[28]; // all-zero reserved area
char data_[0]; // property area; a binary search tree whose nodes are prop_bt
The property area is a variant of a trie / binary search tree; each node is a prop_bt:
struct prop_bt {
uint32_t namelen; // name length
atomic_uint_least32_t prop; // if this node is a property, prop is the offset of the prop_info in the prop_area
atomic_uint_least32_t left; // left subtree: names < this node's name
atomic_uint_least32_t right;// right subtree: names > this node's name
atomic_uint_least32_t children;// next-level child node
char name[0];// variable-length name
}
The comment above prop_bt illustrates it. Take ro.secure=1:

Figure 1 — The prop_bt tree for ro.secure=1
rois inserted first;netandsysare inserted later and, being siblings ofro, become its left/right subtrees.ro.com’scomnode is a sibling ofsecure; inserted later, socombecomessecure’s left subtree.ro.secureis 1, sosecure’sproppoints to aprop_infoholding the name and value.
The prop_info structure:
struct prop_info {
atomic_uint_least32_t serial;// sync variable for modifying the value; also encodes the value length.
union {
char value[PROP_VALUE_MAX];// property value string
struct {
char error_message[kLongLegacyErrorBufferSize];
uint32_t offset;
} long_property; // long property: value length >= PROP_VALUE_MAX
};
char name[0]; // property name string
}
Back to prop_area::add(...), which adds a property — the binary-search-tree insert. Understanding it makes traversal and update easy, since they’re the same tree operations. add calls find_property, and find also calls find_property; the difference is the last parameter alloc_if_needed: when true, a missing node is created.
prop_area::find_property analysis
- Split the property name by
.; from the root node, loop processing each segment. - Each iteration finds the current node’s child; if
alloc_if_neededis true and no child, callnew_prop_btto create one. - Find the node matching the current segment among the children: equal → that child is the next node; smaller → its left subtree; larger → its right subtree. Comparison: compare lengths first (shorter is smaller); if equal, use
strncmp’s result. If the subtree is missing andalloc_if_neededis true,new_prop_btcreates it. - On the last segment the loop ends; for a find, return the current node’s
prop(theprop_info*, possibly null); for an add, callnew_prop_infoto create aprop_infoand return it, withvalue_length << 24stored inserial.
Back in init’s SecondStageMain(), StartPropertyService opens the socket to wait for property-set requests from other processes: PROP_MSG_SETPROP and PROP_MSG_SETPROP2. From here, apps can use the property system.
Ordinary apps can read /dev/__properties__/properties_serial and /dev/__properties__/property_info, but not all SELinux-context-named files — meaning some properties are unreadable to apps. When iterating properties, ContextNode::CheckAccessAndOpen checks and opens each context file under /dev/__properties__; for SELinux-denied files it returns false, so the app never sees those properties. The properties an app may read are specified in system/sepolicy/public/domain.te:
# Public readable properties
get_prop(domain, debug_prop)
get_prop(domain, exported_config_prop)
get_prop(domain, exported_default_prop)
get_prop(domain, exported_dumpstate_prop)
get_prop(domain, exported_fingerprint_prop)
get_prop(domain, exported_radio_prop)
get_prop(domain, exported_secure_prop)
get_prop(domain, exported_system_prop)
get_prop(domain, exported_vold_prop)
get_prop(domain, exported2_default_prop)
get_prop(domain, logd_prop)
Property classification
The classifications mentioned earlier:
ro-prefixed read-only properties — can only be set once.ctl-prefixed control properties — only these values: a)ctl.sigstop_onb)ctl.sigstop_offc)ctl.startd)ctl.stope)ctl.restartf)ctl.interface_startg)ctl.interface_stoph)ctl.interface_restartctlproperties send commands to services ininit; the value is a service/interface name, e.g."ctl.start zygote"calls thezygoteservice’sStart.persist-prefixed persistent properties. Normal properties aren’t persistent — they’re reinitialized after reboot. Persistent properties keep their value across reboots, stored in/data/property/persistent_properties.selinux.restorecon_recursive— restores the SELinux context of the value’s file. Special:initforks a child to callselinux_android_restorecon. Currently only used in vold.- Other properties.
Property synchronization
Properties are shared between processes, and different processes can read/write them, so they need synchronization. It relies on the futex syscall, whose first argument must be shared across processes via mmap. Two variables provide this argument:
- The
serial_member of theprop_areamapped from/dev/__properties__/properties_serial. - The
serialmember of each property’sprop_info.
Both serials are atomic_uint_least32_t — atomic variables.
The futex syscall implements both event notification and mutexes; bionic wraps it in __futex_wait/__futex_wake.
Case 1 — serial_ in /dev/__properties__/properties_serial: starts at 0; when a property is added/updated it increments by 1 and __futex_wake wakes all waiting processes. Those processes wait by calling __system_property_wait (first arg nullptr waits on this serial), which calls __futex_wait. Since adding/updating changes serial_, __system_property_wait can wait for any property add/update. serial_ is the “global serial”.
Case 2 — prop_info’s serial: initialized to value_length << 24. When a value is updated, first set serial |= 1 to notify others it’s being modified; after the update, set serial = (len << 24) | ((serial + 1) & 0xffffff), then notify all processes waiting on the change.
What happens if one process is modifying a property while another reads it? Look at SystemProperties::Get’s synchronization in system_properties.cpp:
- First call
SystemProperties::Find(const char* name)to get theprop_info. - Then
SystemProperties::Read(const prop_info* pi, char* name, char* value)reads the value intovalue. It first enters a loop, callingSerialand waiting untilserial & 1isn’t 1 — if it is, some process is modifying, so wait until it finishes and read the updated value. If the reader sees no modification, and another process starts modifying, the reader re-checks after reading whether the value changed; if so, it read a stale value and loops to read again. So with multiple readers, some may read the old value, some the new. Sinceinithandles property-update requests serially over the socket, there’s never multiple updaters.
Part 2. Detecting property-based device spoofing
Now that the internals are clear, here’s my take on property spoofing. Property spoofing is best done per-app: only the targeted app sees modified properties, without affecting global properties — modifying global properties often breaks the system. Per-app spoofing also makes the control logic easier and enables one-click re-spoofing without a reboot.
Detection has two main angles:
- Detect whether the property implementation mechanism is sound.
- Detect whether the obtained property values are suspicious, e.g.
ro.debuggable=1.
Here I focus on the first: detecting a broken property implementation. Main checkpoints:
- Check that the SELinux the property implementation relies on is properly enabled.
- Obtain properties through multiple paths and check whether the values agree.
- Property spoofing uses I/O redirection — check for it. (Other checks — root, Frida, Xposed — aren’t directly property-related, so I’ll skip them.)
Checkpoint 1, SELinux: once the vendor enables SELinux, Android generally follows Google’s policy. First call getenforce to check SELinux state, then access files the public SELinux policy forbids — if accessible, the SELinux implementation is broken. For example, apps can’t open /dev/socket/property_service, and some property files are also denied. Many system files can serve this check. Also read /proc/self/mounts to verify selinuxfs is mounted at /sys/fs/selinux.
Checkpoint 2 logic:
- First call
getpropto dump all properties as a baseline; compare other paths against it. Any difference basically means a broken property implementation — a suspicious device. - Compare properties from
android.os.SystemProperties.get(needs reflection to call). - Compare properties from the
android.os.Buildclass — initialized when zygote preloads classes, so it’s fetched very early and may hold the original properties. - Compare via JNI calls to
__system_property_get. - Compare via JNI calls to
__system_property_findand__system_property_read_callback(needs#if __ANDROID_API__ >= 26). - Compare via JNI calls to
__system_property_foreachand__system_property_read_callback(needs#if __ANDROID_API__ >= 26). - Compare the “getprop + property name” result.
- Parse
/system/etc/selinux/plat_property_contextsyourself and check whether the obtained context matches. - Write your own parser for
/dev/__properties__without using libc’s interface, and compare.
The last one copies the property-related system code into JNI, generating its own interface to fetch properties, fully independent of the system runtime:

Figure 2 — Self-contained property parsing in JNI
Checkpoint 3 logic:
Many property-spoofing approaches I/O-redirect the files under /dev/__properties__, so reading /dev/__properties__ actually reads files elsewhere. I/O redirection has many implementations; here are checks for some.
- Some redirections patch the kernel’s
do_sys_opento pass a different filename todo_filp_open. It works, but exposes itself under/proc/pid/fd— the opened fd points at the redirected file. Detection:
static std::string get_selfpath(int fd) {
char path[PATH_MAX];
snprintf(path, PATH_MAX, "%s/%d", "/proc/self/fd/", fd);
char buff[PATH_MAX];
ssize_t len = ::readlink(path, buff, sizeof(buff) - 1);
if (len != -1) {
buff[len] = '\0';
return std::string(buff);
}
return "";
}
static bool checkFileFDLinkName(std::string filename) {
int fd = open(filename.c_str(), O_RDONLY);
__android_log_print(ANDROID_LOG_INFO, LOG_TAG, "filename:%s,fd:%d", filename.c_str(), fd);
std::string link_path = get_selfpath(fd);
__android_log_print(ANDROID_LOG_INFO, LOG_TAG, "link path: %s", link_path.c_str());
return filename == link_path;
}
Then call:
checkFileFDLinkName("/dev/__properties__/property_info")
&& checkFileFDLinkName("/proc/cpuinfo")
&& checkFileFDLinkName("/proc/meminfo")
&& checkFileFDLinkName("/dev/__properties__/u:object_r:default_prop:s0")
Logic: with this kind of redirection, opening /proc/cpuinfo redirects to some /xxx file. Suppose open("/proc/cpuinfo") yields fd 5; readlink("/proc/pid/fd/5") returning /xxx instead of /proc/cpuinfo means something’s wrong.
- Check whether
openandopenatreturn identical content. Some redirections decide whether to redirect by inspecting thefilenameargument ofdo_sys_openin the kernel, ignoring thedfdargument. Splitting the path and callingopenatthen — if the content differs fromopen’s, something’s wrong.
static bool checkOpenAt(std::string filename, std::string dir, std::string pathname) {
int openfd = open(filename.c_str(), O_RDONLY);
std::string open_content;
ReadFdToString(openfd, &open_content);
int dirfd = open(dir.c_str(), O_RDONLY);
int openat_fd = openat(dirfd, pathname.c_str(), O_RDONLY);
std::string openat_content;
ReadFdToString(openat_fd, &openat_content);
return open_content == openat_content;
}
Call: checkOpenAt("/proc/cpuinfo", "/proc", "cpuinfo").
The checks above can detect property spoofing to a large degree.
Part 3. Custom-source anti-detection
So how do you build your own property-spoofing scheme? Goals:
- Bypass all the checks above.
- Apply only to certain apps.
- No reboot required.
- Don’t modify the original properties.
- Performance can’t be too bad.
Here’s my implementation. To bypass all the checks, the fake properties must look exactly like real ones: the SELinux context matches, the property area exists and matches, and every interface returns consistent results. Except for the fake content, everything behaves like the real system.
First prepare a property file and /system/etc/selinux/plat_property_contexts and /vendor/etc/selinux/vendor_property_contexts. These three can be taken from a real device (e.g. Huawei, Oppo). The last two need a script to rewrite vendor-defined SELinux contexts into contexts that already exist on the spoofing phone — otherwise you’d have to modify the SELinux policy to add those contexts.
Build a new property directory, say /dev/xxx/__properties__. init initializes both /dev/__properties__ and /dev/xxx/__properties__. The headers <sys/system_properties.h> and <sys/_system_properties.h> need two copies; I named them system_properties_ext.h and _system_properties_ext.h, with every function gaining an _ext suffix.
With the interface ready, prepare an implementation system_property_api_ext.cpp with a static SystemProperties system_properties_ext; whose dependency path changes from /dev/__properties__ to /dev/xxx/__properties__. Then init can initialize the new area with the new interface:
#define EXT_ROOT_DIR /dev/xxx
void property_init_ext() {
mkdir(EXT_ROOT_DIR, S_IRWXU | S_IXGRP);
mkdir(EXT_ROOT_DIR "/__properties__", S_IRWXU | S_IXGRP | S_IXOTH);
CreateSerializedPropertyInfoExt();// reads the prepared plat_property_contexts and vendor_property_contexts instead
if (__system_property_area_init_ext()) {
LOG(FATAL) << "Failed to initialize property area";
}
if (!property_info_area_ext.LoadDefaultPathExt()) {
LOG(FATAL) << "Failed to load serialized property info file";
}
init_ext_properties();
}
CreateSerializedPropertyInfoExt and __system_property_area_init_ext are both switched to the /dev/xxx/__properties__ area.
init_ext_properties sets the property values: it parses the prepared property file and calls InitPropertySetExt:
void init_ext_properties() {
auto file_contents = std::string();
const std::string filename = "/xxx/xxx/my_prop";
if (!ReadFileToString(filename, &file_contents)) {
PLOG(ERROR) << "Could not read properties from '" << filename << "'";
return;
}
for (const auto& line : Split(file_contents, "\n")) {
auto trimmed_line = Trim(line);
if (trimmed_line.empty() || StartsWith(trimmed_line, "#")) {
continue;
}
auto tokenizer = SpaceTokenizer(line);
auto key = tokenizer.GetNext();
auto value = tokenizer.GetRemaining();
if (key.empty() || value.empty()) {
continue;
}
InitPropertySetExt(key.substr(1, key.size() - 2), value.substr(1, value.size() - 2));
}
}
After property_init_ext, the new /dev/xxx/__properties__ area works. Copy the getprop program, add the _ext suffix to all its interfaces, recompile — if it correctly shows the modified properties and their SELinux contexts, the new area is sound.
Next, the target apps. As said, zygote already called __system_properties_init to initialize the property area, and every app is forked from zygote, so the app inherits zygote’s property area. We need to switch the app to /dev/xxx/__properties__ without leaving a trace in mmap. The mechanism is unshare + bind mount.
SpecializeCommon in com_android_internal_os_Zygote.cpp is the “specialization” function run just before forking a new app process; before it, the process still runs as root and only drops capabilities/resources later. I hook there, calling a new bind_mount_early:
static void bind_mount_early(uid_t uid, fail_fn_t fail_fn){
if(getuid() == AID_WEBVIEW_ZYGOTE){
return;
}
if (unshare(CLONE_NEWNS) == -1) {
fail_fn(CREATE_ERROR("Failed to unshare(): %s", strerror(errno)));
}
if(!need_extra_prop(uid)){
return;
}
// Create a second private mount namespace for our process
if (TEMP_FAILURE_RETRY(mount("/dev/xxx/__properties__", "/dev/__properties__", nullptr,
MS_BIND | MS_REC, nullptr)) == -1) {
fail_fn(CREATE_ERROR("Failed to mount /dev/xxx/__properties__ to /dev/__properties__: %s",
strerror(errno)));
}
}
First check for AID_WEBVIEW_ZYGOTE — the webview zygote needs no handling.
unshare(CLONE_NEWNS): a normal app would call unshare(CLONE_NEWNS) later in MountEmulatedStorage to create a new mount namespace; I move that call earlier to prepare for the bind mount, binding /dev/xxx/__properties__ onto /dev/__properties__. Now the app’s /dev/__properties__ is redirected to /dev/xxx/__properties__, and since it’s a new mount namespace, it only affects this process.
Then reinitialize the property area:
static void handle_ext_prop(uid_t uid, fail_fn_t fail_fn){
if(!need_ext_prop(uid)){
return;
}
__system_properties_abandon();
__system_properties_init();
}
__system_properties_abandon is my new function that releases all memory mappings of the previous property area; then __system_properties_init (the original system function) initializes — but thanks to the bind mount, it actually initializes the /dev/xxx/__properties__ area, invisibly to the app.
android.os.Build needs extra handling — it initializes much earlier than SpecializeCommon. I handle it in ActivityThread::handleBindApplication: if the process needs property spoofing, reflectively update all properties in Build.
The bind mount also leaves a signature: /proc/pid/mountinfo, /proc/pid/mounts, and /proc/pid/mountstats show /dev/__properties__ as bind-mounted. So patch the kernel to remove it. The logic for these three files ultimately reaches seq_path_root in fs/seq_file.c, where p = __d_path(path, root, buf, size) returns a pointer to the bind point’s string. Just modify that string — e.g. if p is /dev/__properties__, rewrite it to something else.
With those adjustments the goal is basically met, but a few problems remain:
- libhwui errors when looking up the OpenGL and gralloc libraries, because those implementations depend on the original properties.
CachedPropertycrashes:
10:43:39.703 6095 6095 F DEBUG : signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x79181ef108
10:43:39.769 6095 6095 F DEBUG : backtrace:
10:43:39.769 6095 6095 F DEBUG : #00 pc 00000000000825e0 /apex/com.android.runtime/lib64/bionic/libc.so (SystemProperties::Serial(prop_info const*)+16) (BuildId: dd26adccaae5614ac299a5a8a88b6c69)
10:43:39.769 6095 6095 F DEBUG : #01 pc 0000000000083c84 /apex/com.android.runtime/lib64/bionic/libc.so (bionic_trace_begin(char const*)+292) (BuildId: dd26adccaae5614ac299a5a8a88b6c69)
10:43:39.769 6095 6095 F DEBUG : #02 pc 00000000000e1db8 /apex/com.android.runtime/lib64/bionic/libc.so (pthread_create+84) (BuildId: dd26adccaae5614ac299a5a8a88b6c69)
Problem 1: the app’s RenderThread uses libhwui, which depends on the OpenGL implementation, which depends on the original properties to find the vendor’s OpenGL implementation — the logic is in the system EGL dispatch library EGL/Loader.cpp. The OpenGL implementation also depends on gralloc, which again depends on original properties. So reading the modified properties breaks it.
Both Android and Linux have an OpenGL shell wrapping the vendor’s OpenGL/EGL implementation; the shell has its own lookup rules for loading the vendor library. On Android it lives in frameworks/native/opengl/libs/EGL; on Linux it’s usually libglvnd: the GL Vendor-Neutral Dispatch library. The main properties Android uses to find the OpenGL implementation are ro.hardware.egl and ro.board.platform. For gralloc: ro.hardware.gralloc, ro.hardware, ro.product.board, ro.board.platform, ro.arch.
Solution: RenderThread is created during app launch, at HardwareRenderer.preload() in ActivityThread::handleLaunchActivity. If system-created threads read the original properties while app-created threads read the modified ones, the problem disappears. pthread thread-local storage (pthread_once, pthread_setspecific, pthread_getspecific) does exactly that. In system_properties.h add:
int __system_property_pthread_setspecific(const void *value);
void* __system_property_pthread_getspecific();
Add a static pthread_key_t my_pthread_key; when fetching a property, check whether the calling thread set the TLS — if so, return the original properties. To get original properties there are two options: a new channel to fetch original values from init, or save the original values before __system_properties_abandon. I chose the latter, since the first isn’t allowed by SELinux and I avoid touching SELinux policy. Then call __system_property_pthread_setspecific after RenderThread starts, solving problem 1.
Problem 2: CachedProperty caches property values; after __system_properties_abandon releases the old mappings, CachedProperty still holds the old prop_info pointer. Solution: if the memory area was released, clear CachedProperty’s prop_info_, cached_area_serial_, and cached_property_serial_.
With these source modifications, the result passes all the earlier checks, and device-verification apps show the modified properties.
P.S. Some big apps initialize egl in another thread ahead of time; for that crash, just call __system_property_pthread_setspecific inside eglInitialize.
Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch