A reproducible, root-free containment procedure for an operator (China Unicom) OMA-DM telemetry component that ships inside
/system/priv-appon HyperOS 4.0. ThepmCLI cannot uninstall or disable it; thePackageInstallerAPI can (measured — see matrix §8).
Target package: com.mobiletools.systemhelper (display name SystemHelper) — the China
Unicom OMA-DM device self-registration / telemetry component, baked into /system/priv-app,
signed with subject O=CU.
Environment: a retail HyperOS 4.0 build (Android 17 / API 37), no root, bootloader locked.
Other HyperOS 4.x and MIUI 14+ devices will most likely behave the same: every command in the
scripts is plain adb shell, and only the "app network" step is a MIUI-specific UI.
🇨🇳 中文版 / Chinese version: README.zh-CN.md
adb devices # make sure the device is connected
bash scripts/lockdown.sh # apply (the default run really changes the device)
bash scripts/verify.sh # print verification data (300 s sampling by default)
bash scripts/rollback.sh # undo everythingState after containment (measured on the test device, 11m09s sampling window):
| Metric | Result |
|---|---|
Processes (ps -A) |
0 |
| Package state | suspended=true stopped=true distractionFlags=2 |
Entries for that uid in netstats |
0 entries / 0 bytes (device-wide entries grew 231 → 246, so accounting was alive) |
| Component launches | 3 attempts, all rejected |
| Background traffic | REJECT_METERED_BACKGROUND + APP_BACKGROUND at the netd layer |
Two different jobs, two different outcomes.
- Uninstall (
installed=false, data gone): thepmCLI is blocked by three different exceptions, but the same uninstall issued through thePackageInstallerAPI — Canta over Shizuku, no root — works. Measured 2026-11-07 on the test device: exactly one package gone out of a 497-package snapshot, the only additions being Canta itself and an unrelated app update;com.qualcomm.qti.services.systemhelperandcom.miui.guardprovideruntouched. Details: docs/matrix-hyperos4.md §8. - Containment (the L1–L5 lockdown below): for when you would rather keep the package in place than remove it — OTA-safe, one-command rollback.
| Field | Value |
|---|---|
| Package | com.mobiletools.systemhelper |
| Code path | /system/priv-app/com.mobiletools.systemhelper/ (privileged app) |
| Version | 4.0.0-20260729 (versionCode 34, minSdk 31, targetSdk 34) |
| Signing cert | CN=NewDM, OU=RI, O=CU, L=BJ, ST=BJ, C=86 → O=CU = China Unicom |
| Install source | installerPackageName=null, firstInstallTime=1970 → baked into the system image at the factory |
| Privileged allowlist | /system/etc/permissions/privapp-permissions-systemhelper.xml, comment reads MIUI ADD: CUST_CmctDm |
| Upload endpoint | https://dm.wo.com.cn:18081/register, plaintext fallback http://dm.wo.com.cn:18080/register (usesCleartextTraffic=true) |
It is not malware in the classic sense: no INSTALL_PACKAGES (cannot silently install apps),
no READ_SMS / READ_CONTACTS / READ_CALL_LOG, no accessibility service, no notification
listener, not a device admin.
It is an operator customization layer — but the "device fingerprint + installed app list" part is real and collects at full speed by default.
Side note: manifests of apps such as
com.xunmeng.pinduoduoandcom.jingdong.app.malldeclare<queries>for this package, i.e. its exportedRegisterHelperService(protected by a signature-level permission) is used by channel apps for model/channel identification.
Fields read straight out of the upload model (g1/b) inside the APK:
IMEI1 IMEI2 IMSI1 IMSI2 ICCID1 ICCID2 MNC1 MNC2
TAC1 TAC2 CID1 CID2 PCI1 PCI2 ← cell tower triple, per SIM
BSSID RSSI Frequency LinkSpeed IPv6 ← Wi-Fi fingerprint
NetworkType1/2 5GEnabled1/2 VoLTE1/2 VoNR1/2
Esim InactiveEsimMNC SatelliteEnabled SatelliteSupport
PairedDevice PairedMacAddress ← paired Bluetooth device name + MAC
Manuf Model OS OSVersion HWVersion SWVersion ClientVersion SpecVersion SubType
DeviceType DataSlot RegType AccType Time
appInfoList ← walks PackageManager, filters only com.android*
In three sentences:
- Who you are: dual-SIM IMEI / IMSI / ICCID + MNC + eSIM state → "this handset = this person" survives a SIM swap.
- Where you are: cell TAC/CID/PCI + Wi-Fi BSSID/RSSI/channel → cell and Wi-Fi triangulation
without ever requesting a location permission (which is exactly why it asks for
ACCESS_FINE_LOCATION; the APK also bundles a full Baidu Location SDK). - What you use: the full installed-app list, excluding only the
com.android*prefix.
Trigger points (RegType in h1/p): A boot + app-list change, B regular, F,
S/T SIM change, R signal change. They map to six components: OnBootReceiver
(BOOT_COMPLETED + QUICKBOOT_POWERON, measured mPriority=999), SIMStateReceiver,
ConnectStateReceiver, ScheduleRegisterReceiver, AppInfoRegisterReceiver,
SignalInfoRegisterReceiver, plus RegisterJobService.
If it cannot upload, it queues locally in SQLite (i1/e) and retries when the network comes
back — so "offline" is not the same as "not collected".
The Xiaomi branch gates on Settings.Global.device_provisioned == 1:
- finishing the out-of-box wizard is enough to open the gate;
- the app exposes no user-facing switch;
Settings.Secure.operator_service_improve_planonly affects the generic branch, not the Xiaomi one (that key was empty on the test device);- both strings are directly searchable inside the APK — this is not guesswork.
Every row below was executed on the test device; output is copied verbatim. Short version:
of six removal paths, only suspend works.
| # | Command | rc | Verbatim output | Verdict |
|---|---|---|---|---|
| 1 | pm uninstall -k --user 0 <pkg> |
1 | Failure [only root can delete system app for a particular user] |
blocked |
| 2 | pm uninstall --user 0 <pkg> |
1 | same as above | blocked |
| 3 | cmd package uninstall --user 0 <pkg> |
1 | same as above | blocked |
| 4 | pm hide <pkg> |
255 | SecurityException: Access denied, requires: android.permission.MANAGE_USERS |
no permission |
| 5 | pm disable-user --user 0 <pkg> |
255 | SecurityException: Cannot disable system packages. (PackageManagerServiceImpl.shouldRestrictEnabledSettingsChange:1116) |
blocked |
| 6 | pm disable --user 0 <pkg> |
255 | same as above | blocked |
| 7 | pm suspend --user 0 <pkg> |
0 | Package … new suspended state: true |
✅ works |
| 8 | pm set-distracting-restriction --flag hide-notifications --user 0 <pkg> |
0 | reads back distractionFlags=2 |
✅ works |
| 9 | pm uninstall-system-updates <pkg> |
1 (stdout Success) |
no update layer to remove (APK lives in /system) |
no effect |
| 10 | pm revoke <pkg> android.permission.ACCESS_LOCAL_NETWORK |
0 | still reads back granted=true |
cannot revoke (Android 16+ permission, granted to privileged apps by default) |
| 11 | adb install -r <stub.apk> — same package, versionCode 35, own key |
1 | INSTALL_FAILED_UPDATE_INCOMPATIBLE: … signatures do not match newer version; ignoring! |
blocked (signature) |
Conclusion: HyperOS 4.0 moved "change a system app's enabled state" behind a private check in
PackageManagerServiceImpl, so the pm uninstall --user 0 trick that works on HyperOS 2/3 fails
outright on 4.0. ro.debuggable=0 (retail build), so adb root is not an option either.
Replacing it with a higher-version stub does not work either. A same-package APK at
versionCode 35 (retested at 999999999) signed with our own key is rejected on all nine install
paths — adb install -r/-t/-d, pm install with --user 0 and --user 999, cmd package install, and the install-create → install-write → install-commit session route (which does accept
the payload, then fails at commit). Signature verification runs before any version comparison; see
matrix §2.5, including the control experiment that shows the wall is the
key, not the priv-app location.
Two counter-intuitive traps worth recording:
pm list packages(including-d/-e) returns rc=0 with zero output; enumeration must usecmd package list packages.am set-standby-bucket <pkg> restricted|rare|working_setreturns rc=0 but the value never changes (bucket=10 reason=fis constant inusagestats); this lever is dead on this device.
Five layers, each independently revertible. Pick the layers you want; you do not have to take all five.
| Action | Command | Evidence |
|---|---|---|
| System-level suspend | pm suspend --user 0 <pkg> |
dumpsys package → suspended=true |
| Force-stop + set the stopped flag | am force-stop <pkg> |
stopped=true (boot broadcasts will not wake it until something starts it explicitly) |
| Standby marker | am set-inactive <pkg> true |
enters the idle list |
| Deny background execution | cmd appops set <pkg> RUN_ANY_IN_BACKGROUND ignorecmd appops set <pkg> RUN_IN_BACKGROUND ignore |
u0a<uid> shows up under Restricted packages in dumpsys alarm / dumpsys jobscheduler = force-standby |
| No foreground service | cmd appops set <pkg> START_FOREGROUND ignore |
cmd appops get reads back ignore |
| No wake locks | cmd appops set <pkg> WAKE_LOCK ignore |
same |
| Action | Command | Evidence |
|---|---|---|
| Revoke exact-alarm permission | cmd appops set <pkg> SCHEDULE_EXACT_ALARM ignore |
dumpsys alarm → Last OP_SCHEDULE_EXACT_ALARM: u0a<uid>:ignore; all five of its *walarm* timers (SIMStateReceiver / OnBootReceiver / AppInfoRegisterReceiver / ScheduleRegisterReceiver / SignalInfoRegisterReceiver) drop out with exact_alarm_permission_revoked |
pm revoke × 6, all reading back granted=false:
android.permission.READ_PHONE_STATE
android.permission.ACCESS_FINE_LOCATION
android.permission.ACCESS_COARSE_LOCATION
android.permission.ACCESS_BACKGROUND_LOCATION
android.permission.READ_EXTERNAL_STORAGE
android.permission.BLUETOOTH_CONNECT
Cannot be revoked:
READ_PRIVILEGED_PHONE_STATE(install-time privileged permission — IMEI may still be readable) andACCESS_LOCAL_NETWORK(Android 16+ permission;pm revokereturns rc=0 but the value does not change).
| Action | Command | Evidence |
|---|---|---|
| Add to the restrict-background blacklist | cmd netpolicy add restrict-background-blacklist <uid> |
cmd netpolicy list restrict-background-blacklist → the uid is listed |
| — | — | dumpsys netpolicy → policy=1 (REJECT_METERED_BACKGROUND), blocked_state={blocked=APP_BACKGROUND|METERED_USER_RESTRICTED} |
This layer is the kernel netd iptables chain doing the blocking — independent of whether the app has a switch or whether it crashes.
pm set-distracting-restriction --flag hide-notifications --user 0 <pkg> → distractionFlags=2,
together with cmd appops set <pkg> POST_NOTIFICATION ignore.
This is the only root-free way on this device to deny it even in the foreground, because it is
MIUI's own firewall and its state lives in a private database owned by com.miui.securitycenter;
adb cannot write it.
Settings → Apps → Manage apps → search "SystemHelper" → App network (应用联网)
Turn all four switches off: Data – SIM 1, Data – SIM 2, WLAN, Background data.
One consequence to accept: with WLAN off, the app cannot reach the network even when pulled to the foreground — that is what "actually offline" means.
Wherever you control DNS, resolve dm.wo.com.cn to 0.0.0.0 (router static DNS pointing at a local
sinkhole, local DNS filtering, or a hosts file).
- One domain covers both upload ports —
:18081(HTTPS) and:18080(plaintext fallback) — because the hostname is the same; - it does not stop hard-coded direct IP connections, so treat this as a finisher on top of §4.1 + §4.4, not as the main defence.
adbonPATH(orADB=/path/to/adb)- USB or wireless debugging enabled,
adb deviceslists the device - No root, no unlocked bootloader
# 0) read-only: prove what is there before touching anything
bash scripts/preflight.sh # environment / guards / dependency surface
bash scripts/audit.sh --no-sample # full read-only audit → logs/<ts>/audit-report.md
bash scripts/snapshot.sh # full state snapshot (rollback depends on it)
# 1) dry run first — prints what it would change, touches nothing
bash scripts/lockdown.sh --dry-run
# 2) apply
bash scripts/lockdown.sh
# 3) produce verification data (300 s sampling by default; --seconds 60 to shorten)
bash scripts/verify.shscripts/restriction/restriction.sh exposes the disposal system as seven explicit modes — nothing
runs unless you name it:
bash scripts/restriction/restriction.sh audit # read-only (default)
bash scripts/restriction/restriction.sh plan # print the plan, change nothing
bash scripts/restriction/restriction.sh restrict # L1–L5 freeze
bash scripts/restriction/restriction.sh disable # attempt disable (blocked on HyperOS 4.0)
bash scripts/restriction/restriction.sh uninstall # attempt per-user uninstall (blocked, needs CONFIRM)
bash scripts/restriction/restriction.sh verify
bash scripts/restriction/restriction.sh restoreEvery action reports one of SUCCESS / FAILED / SKIPPED / UNKNOWN; a command is never marked
SUCCESS unless the state is read back and matches.
With several devices attached add --serial <serial>; for a different package add --pkg <name>
(the script carries a deny-list so it will not touch lookalikes such as Qualcomm's
com.qualcomm.qti.services.systemhelper).
The scripts dump raw dumpsys package / appops / netstats output into ./logs/<timestamp>/ so
you can diff before and after.
Do not stop at "no processes" — this app is a short-lived
wake → collect → upload → exit process, so an empty ps at any given moment says nothing about
whether it ran today.
Four metrics carry the argument:
- Processes:
ps -A | grep mobiletools→ empty - Package state:
dumpsys package <pkg> | grep -E 'stopped=|suspended=|distractionFlags='→stopped=true suspended=true distractionFlags=2 - Traffic delta: count
uid=<uid>rows andrb/tbindumpsys netstats detail, sampled twice at least 5 minutes apart- Methodology note: compare UID stats only, never add
UID tag statson top, or the same bytes get counted twice - Zero rows for the whole uid = not a single byte in that accounting window
- Methodology note: compare UID stats only, never add
- Alarms:
dumpsys alarm | grep -A3 'Restricted packages'→u0a<uid>should appear;Last OP_SCHEDULE_EXACT_ALARMshould beignore
scripts/verify.sh runs 1–4 and prints PASS/FAIL.
Caveat:
dumpsys netstats detailonly reflects the current accounting window. For "how much has it uploaded historically", read the cumulative counters in Settings → Apps → App network (a separate persistent counter).
bash scripts/rollback.sh # reverses appops / netpolicy / suspend / notification restrictionManually, command by command:
pm unsuspend --user 0 com.mobiletools.systemhelper
pm set-distracting-restriction --flag 0 --user 0 com.mobiletools.systemhelper
cmd appops set com.mobiletools.systemhelper RUN_ANY_IN_BACKGROUND default
cmd appops set com.mobiletools.systemhelper RUN_IN_BACKGROUND default
cmd appops set com.mobiletools.systemhelper START_FOREGROUND default
cmd appops set com.mobiletools.systemhelper WAKE_LOCK default
cmd appops set com.mobiletools.systemhelper POST_NOTIFICATION default
cmd appops set com.mobiletools.systemhelper SCHEDULE_EXACT_ALARM default
cmd netpolicy remove restrict-background-blacklist <uid>
am set-inactive com.mobiletools.systemhelper false
pm grant com.mobiletools.systemhelper android.permission.READ_PHONE_STATE # same for the other permissionsNothing was uninstalled, so
cmd package install-existingis not needed; ifpm grantis refused (privileged permissions), a reboot normally restores the defaults.
- It is not removed. The APK stays in
/system/priv-appandpm pathstill finds it. Real removal means either root +mount -o rw,remount /system, or an unlocked bootloader and a custom ROM. This device isro.debuggable=0, so evenadb rootis unavailable. - A reboot undoes part of it.
suspendsurvives,stoppedis reset by the boot flow (measured baseline after reboot:stopped=false) → re-runam force-stopafter every boot. - OTA restores state. A system update overwrites
/system, permissions may return toGRANTED_BY_DEFAULT, and appops / netpolicy may be reset → re-run everything after upgrading. - Privileged reads such as IMEI cannot be blocked.
READ_PRIVILEGED_PHONE_STATEis an install-time permission; containment relies on "no execution" + "no network". - The local queue does not disappear. Whatever it collected sits in SQLite waiting to be
uploaded;
pm clearwipes it once, but if it gets started again it will refill. - Do not rely on the DNS layer alone. It stops neither direct IP connections nor already-established ones.
| Pitfall | Detail |
|---|---|
pm list packages prints nothing |
On HyperOS 4.0 it returns rc=0 with zero bytes for shell; enumerate with cmd package list packages |
am set-standby-bucket has no effect |
Constant bucket=10 reason=f for this package whatever you set |
pm uninstall --user 0 is dead |
Success reports for HyperOS 2/3 do not hold on 4.0 |
pm hide needs MANAGE_USERS |
The shell uid does not have it |
Component name is not necessarily .service.RegisterIntentService |
The Service Resolver Table in dumpsys package only lists exported ones; the exported one here is .service.RegisterHelperService |
| Boot broadcast priority | The internet says 2147483647; dumpsys on this device reports mPriority=999 |
| Running UI on a virtual display | settings put global overlay_display_devices … creates the extra display, but am start --display N is blocked by SafeActivityOptions.checkPermissions (needs INTERNAL_SYSTEM_WINDOW); without root shell cannot use it |
- Code and docs: MIT, see LICENSE.
- Use it only on devices you own or are explicitly authorized to administer. This repository contains no third-party app binaries and no real device or personal data; every number here comes from a single test device, and every path/UID in the scripts is resolved at runtime.
- Package names, certificate DNs and hostnames in this repository come from public Android package metadata and APK strings; they are used to describe the problem and imply no vendor position.
English (primary):
- docs/what-it-collects.md — collected fields and code-level evidence
- docs/matrix-hyperos4.md — every command with its verbatim failure text
- docs/verification.md — measurement methodology: netstats semantics, sampling length, sources of false positives
- docs/faq.md — FAQ and counter-examples
中文 / Chinese:
- docs/zh-CN/what-it-collects.md
- docs/zh-CN/matrix-hyperos4.md
- docs/zh-CN/verification.md
- docs/zh-CN/faq.md
项目工程文档(v2.0/v2.1,中文):
| 文档 | 内容 |
|---|---|
| ARCHITECTURE.md | 分层架构、模块边界、数据流、状态机 |
| THREAT_MODEL.md | 资产、威胁主体、信任边界、残余风险 |
| PRIVACY_AUDIT.md | 五级证据分级、采集字段、离线积压机制 |
| NETWORK_ANALYSIS.md | 端点、触发条件、观测口径、封锁层次 |
| PERMISSION_ANALYSIS.md | 权限三层、可撤销性、顺序坑 |
| COMPATIBILITY.md | 跨版本能力矩阵与命令差异 |
| COMPATIBILITY-hyperos4.md | 本机逐条原始报错 |
| RECOVERY.md | 故障恢复(搞坏了怎么救) |
| RESTORE_GUIDE.md | 正常回滚(按快照恢复原值) |
| RESTRICTION_GUIDE.md | 五层处置:原理/条件/命令/预期/恢复 |
| DISABLE_GUIDE.md | 停用:为什么 HyperOS 4.0 拒绝 |
| UNINSTALL_GUIDE.md | 用户级卸载:13 项检查与硬墙 |
| UNINSTALL_RISK_MATRIX.md | 卸载风险逐项打分 |
| TEST_PLAN.md | 20 个 mock 场景与 CI |
| SECURITY_LIMITATIONS.md | 自我限制清单与不可宣称的结论 |