Stealth Posture: Threat Model and Design Doc¶
Status: partial v0.4, balance deferred to v0.6
v0.4 shipped PR5 + PR6 (plumbing only) — the /flash_dir →
/data/adb/modules_update/ mount-target swap and the
stealth_paths: dict[str, str] registry slot + snapshot/restore
round-trip. PR1 (the actual /data/adb/modules/<random>/...
Frida-path move) is explicitly deferred to v0.6 pending stealth
research — see the prerequisite documented inline in
§3.1. PR2 / PR3 / PR4 / PR7
remain in the v0.6 backlog (no change). The
v0.4 implementation roadmap has
per-PR status.
This doc lands the threat model, current exposure inventory, mitigation playbook, and v0.4 implementation roadmap for Beetroot's stealth posture. The goal is to give v0.4 implementers a written spec to build against, and to stop ongoing themes (T6 snapshots, T7 helper scripts) from baking in new hardcoded paths that v0.4 will have to undo.
1. Threat model¶
Beetroot exists so a researcher can attach Frida to an app that suspects it might be on a rooted device. That suspicion is driven by code shipped in GMS (Google Mobile Services), the Play Integrity API (which replaced SafetyNet Attestation in 2024), and the DroidGuard VM that backs both. The same fingerprinting code is also distributed in many banking, gaming, and anti-cheat SDKs that reuse Google's attestation results or run parallel checks of their own.
The detection surface they sweep is well documented. The categories relevant to Beetroot's container are:
1.1 Frida-server fingerprints¶
- The binary on disk at
/data/local/tmp/frida-server. The canonical path. A directory listing of/data/local/tmp/is the first thing nearly every commercial RASP product checks. The stringfrida-serverinside any file in that directory is enough. - Abstract Unix sockets named
@re.frida.serverand@frida:rpc-*. Visible to any process via/proc/net/unix. Frida binds these unconditionally on startup; the leading@denotes the abstract namespace. - Thread / process names containing
frida. Visible via/proc/<pid>/comm,/proc/<pid>/cmdline, and the per-thread files under/proc/<pid>/task/<tid>/comm. Frida spawns helper threads namedfrida-helper,gum-js-loop,gmain, and similar — those are nearly as distinctive as the literal stringfrida. - A listening TCP port (default 27042). Less load-bearing because the port number is configurable, but a connect-and-banner-check reveals the D-Bus handshake unambiguously.
See Frida.re's anti-anti-Frida notes and the public write-up Detecting Frida the Easy Way (Pulsesecurity, 2024) for catalogued detection patterns; the same indicators appear in DroidGuard string tables.
1.2 Root / Magisk fingerprints¶
Most of these are mitigated today by entrypoint.sh enrolling
com.google.android.gms and com.google.android.gms.unstable in
Magisk's denylist, plus Zygisk's own
Shamiko
module hiding Magisk-related paths from denylisted processes. They are
documented here because the mitigation is fragile — Shamiko is an
external module the user must opt into via examples/stealth.yaml, and
GMS occasionally ships new checks that aren't yet covered.
/system/bin/su,/system/xbin/su,which sureturning non-empty. Classic root checks./data/adb/magisk*,/sbin/.magisk/,/data/adb/modules/. Magisk's own filesystem footprint. Shamiko hides these from denylisted callers via mount-namespace tricks.getprop ro.build.tags=test-keys,ro.debuggable=1,ro.secure=0. Build-flavor giveaways. redroid sets these to match a production AOSP build by default, but new GMS versions sometimes probe additional props (ro.build.fingerprintsubstring matches,ro.bootloader=unknown)./proc/<pid>/mapscontaininglibmagisk.soorzygisk.so. Linker map inspection. Zygisk's ownDenyListnamespace switch removes these for denylisted processes.
1.3 Beetroot-specific fingerprints (this doc's focus)¶
These are the indicators Beetroot itself adds on top of redroid + Magisk. They are the ones v0.4 has to mitigate, because no upstream project will do it for us:
/system/etc/init/stealth.rc. A non-AOSP init file with a literally-named giveaway. Init files in/system/etc/init/are enumerable by anything with permission toreaddirthat path (which is more processes than you'd think — the file mode is0644)./entrypoint.shat filesystem root. Real Android images do not have a file at/entrypoint.sh. Any process that runs astat("/entrypoint.sh", &st)and getsENOENT == 0knows immediately it is inside a container.(resolved in v0.4 PR5). v0.3's Beetroot-invented/flash_dir/mount/flash_dir/mountpoint was a tell — non-Android, visible in/proc/mountsand/proc/self/mountinfo, and named giveawayingly. v0.4 moved the bind-mount target to/data/adb/modules_update/(Magisk's standard module-staging dir), removing the bespoke name from the mount table. v0.3 instances rebind on the firstdown + upcycle after upgrade — see the migration guide.- Frida launched as PID-N child of
/entrypoint.sh. The parent process offrida-serveris normally PID 1 (when launched via ADB-as-root) or a shell PID. Under Beetroot it's always our entrypoint shell, which has a distinctive cmdline.
1.4 Drift assumption¶
This list grows. Each new GMS / Play Services release ships new fingerprints; researchers find and publish new detection vectors quarterly. v0.4 ships a CI fingerprint audit (see §3.7) that runs an open-source detector against the built image and fails CI on new hits, so we catch regressions in our own container instead of waiting for users to report them.
2. Current exposure inventory¶
The following indicators are present in the v0.3 container image
(docker build of docker/Dockerfile against the redroid base + any
starter beetroot.yaml). Mitigation owner refers to which v0.4 PR
(see §7) takes responsibility.
| Indicator | Container path / signal | Risk | Mitigation owner |
|---|---|---|---|
| Frida binary at canonical path | /data/local/tmp/frida-server |
Critical | PR1 (frida path randomization) |
| Frida thread / cmdline names | /proc/<pid>/comm, /proc/<pid>/task/<tid>/comm containing frida / gum-js-loop |
Critical | PR3 (name evasion) + PR2 (Gadget) |
| Frida abstract socket | @re.frida.server and @frida:rpc-* in /proc/net/unix |
Critical | PR2 (Gadget — no server, no socket) |
| Frida TCP listener | :27042 (default) bound on all interfaces inside container |
Medium | PR2 (Gadget removes the listener) |
| Custom init.rc | /system/etc/init/stealth.rc |
High | PR4 (move to Magisk module) |
| Entrypoint shell at fs root | /entrypoint.sh (mode 0755, owned by root) |
High | PR4 (move to Magisk module) |
| Module flash directory mount | /flash_dir/ bind-mount; visible in /proc/mounts |
High | PR5 (move to modules_update/) |
| Magisk on-disk paths | /data/adb/magisk*, /sbin/.magisk/ |
High | upstream (Shamiko); we just denylist |
| Magisk DB SQL write traces | /data/adb/magisk.db modified by us; mtime / access patterns observable |
Low | PR4 (helper runs in standard phase) |
BEETROOT_* env vars in container |
Container env visible via /proc/1/environ (PID 1 is init, normally locked-down) |
Low | PR1/PR6 (use already-stealthed names) |
| Parent PID of frida-server | getppid(2) returns the entrypoint shell's PID, not init |
Medium | PR2 (Gadget — Zygote is parent) |
The Mitigation owner column references the v0.4 PRs in
§7. The
T7 helper script contracts
already accept all container paths as BEETROOT_* env vars with safe
defaults; see §4 for the
full list. v0.4's path randomization (PR1, PR4, PR5, PR6) is therefore a
pure compose-template + env-var change — no helper code edits.
3. Mitigation playbook (priority order)¶
The order below is risk-weighted — start with what most reliably trips DroidGuard's filesystem sweep, then work down to the indicators that only fire on second-pass scans. Items 1–5 are independent and can ship as separate PRs in parallel; items 6 and 7 are cross-cutting and land after.
3.1 Move Frida off /data/local/tmp/¶
v0.4 plumbing-only; default-flip deferred to v0.6
v0.4 lands the wiring — BEETROOT_FRIDA_BIN is plumbed end-to-end
through render_env, the bundled compose template, and the helper
shells, and RedroidBackendConfig.stealth_paths plus
registry.set_stealth_paths plus the snapshot/restore
path_layout round-trip are all in place — but the default
container-side Frida path is still /data/local/tmp/frida-server.
The actual default-flip to /data/adb/modules/<random>/bin/<random>
is deferred to v0.6 pending stealth research:
user concern: GMS may scan the entirety of /data/adb/modules/
regardless of Shamiko's namespace switch, so the naïve
/data/adb/modules/<random>/... choice may not actually buy us
anything. v0.6's PR1 is gated on a written decision in this
section's "research prerequisite" callout below; once the chosen
path is validated, the default flip is a one-line change in
Instance.create's stealth_paths generator.
Research prerequisite (v0.6)
Output: a written decision in this section with the validated
path. Candidates: (a) another installed Magisk module's tree
(piggy-back on a legitimate module's directory), (b) an
inaccessible-by-default Android path (e.g.
/data/data/<random-uid-app>/files/), (c) Frida Gadget mode
entirely so there is no on-disk frida-server (cross-refs §3.2).
The single highest-value change. Frida-server moves from
/data/local/tmp/frida-server to a per-build randomized path of the
form /data/adb/modules/<random>/bin/<random>, where the two
<random> segments are independent base32 strings of at least 12
bytes each, generated at beetroot create time and persisted in the
registry.
Living under /data/adb/modules/ has three benefits:
- The directory is automatically Magisk-denylisted — denylisted
processes (notably GMS) can't
readdirit via Shamiko's namespace switch. - It looks like a legitimate Magisk module install, so even if a
detection scan widens to
/data/adb/, the contents pattern-match Magisk's own naming. - The path includes the executable's containing module ID, so killing the module also removes Frida — no orphaned binary across resets.
The host bind-mount target moves accordingly; frida_download.py is
unchanged because it never knew the container-side path (the CLI
reads it from settings, not from a constant).
3.2 Frida Gadget mode via Zygisk¶
The long-term answer, and the one that makes most of §1.1 vanish.
Instead of running frida-server as a standalone process, ship
frida-gadget.so as a Zygisk module that injects into a configured
list of target processes at fork time. There is no server process,
no listening socket, no on-disk binary that isn't already a Zygisk
module library, and the parent PID is Zygote (which is normal).
For the host-side consumer: the Frida transport no longer connects via
TCP host port-forward to a server. Instead, the Gadget library opens
a Unix socket on a configurable path inside /data/local/tmp/ (also
randomized), and you point frida -H (via beetroot frida-addr) at an
ADB-forwarded local port that wraps that socket. The CLI surface is
unchanged — beetroot frida-addr simply emits whatever endpoint the
active transport needs; only the internal transport differs. Researchers
who use the Python API switch from
frida.get_device_manager().add_remote_device("localhost:27042") to
frida.get_usb_device().attach(target) after running
adb forward tcp:27042 localfilesystem:<socket-path> (the CLI's
beetroot status will print the right address).
This is harder than items 3.1 / 3.4 because it changes the
host-side wire protocol and breaks the
current Frida guide's "remote device" example.
It is item 2, not item 1, on purpose: ship 3.1 first so users who
need a server-mode Frida (e.g. for frida-trace -i) can still get
one with the worst path-fingerprint already mitigated.
3.3 Process / thread name evasion¶
Cover the indicators that survive §3.1 — the thread names visible in
/proc/<pid>/task/<tid>/comm. Two approaches, in increasing
implementation cost:
LD_PRELOADshim that interceptsprctl(PR_SET_NAME, ...)calls and rewrites strings matchingfrida/gum-/gjs-to innocuous names (e.g.RenderThread,binder:). Cheap; can be a 100-line.sowe ship in the Magisk module from §3.1.- Patched Frida build that renames threads at compile time. The proper fix, but expensive: requires forking frida-core, tracking upstream releases, rebuilding on every Frida version bump. Probably worth doing as a CI job that builds + caches the patched binaries per version.
Note that this is incomplete by construction. Frida's own
internals use thread names for routing and debugging; some are
load-bearing and renaming them breaks Frida (e.g. gum-js-loop
is what Frida looks for when bridging to the JS engine). v0.4
implementers should expect to ship a hand-curated rename map, not a
blanket regex.
3.4 Replace /entrypoint.sh + stealth.rc with a Magisk module¶
This is the largest single drop in visible-from-userspace surface.
The Magisk module model: a directory under
/data/adb/modules/<random>/ containing a
service.d/<random>.sh script. Magisk runs all service.d/*.sh
scripts during the standard post-fs-data phase, under the same
SELinux context (u:r:magisk:s0) we use today. There is no
custom init.rc, no /entrypoint.sh at filesystem root, and the
script's parent directory is denylist-hidden from GMS just like
§3.1.
The Dockerfile's COPY step changes from copying into
/system/etc/init/ and / to copying into the staging area for
Magisk's modules_update/ directory (see §3.5), and a module.prop
file gets generated at build time with a randomized module ID.
The boot trigger changes from "sys.boot_completed=1 → init runs
exec_background" to "Magisk post-fs-data → all service.d/*.sh run".
This is earlier than sys.boot_completed=1, so the script has to
poll for the Zygote start before it can write to the denylist —
T7's magisk-config.sh
already does this, so no helper change needed.
3.5 Drop /flash_dir; bind-mount to /data/adb/modules_update/¶
/flash_dir is a Beetroot-invented name with no upstream meaning.
Magisk already has a well-known directory for staging modules
pending install: /data/adb/modules_update/. When Magisk's daemon
sees a complete module tree in that directory at boot, it moves it
to /data/adb/modules/ and runs the install.
The host-side bind-mount target moves accordingly; compose.yaml
gets a new ${MODULE_STAGE_PATH} substitution that defaults to
/data/adb/modules_update. The CLI's
apply flow is unchanged because it doesn't
know the container-side path either.
3.6 Per-build path randomization¶
§3.1, §3.4, and §3.5 each introduce a <random> segment. They are
not independent random values; v0.4 generates a single
stealth_paths: dict[str, str] blob at beetroot create time and
persists it in the registry entry. Subsequent up / down /
apply / snapshot / restore operations read from the same blob,
so the path stays stable across the instance's lifetime.
The compose template gets new env vars:
BEETROOT_FRIDA_BIN— full path to frida-server inside container.BEETROOT_MODULES_DIR— container-side bind-mount target forinstances/<name>/modules/.BEETROOT_STEALTH_MODULE_ID— randomized Magisk module ID that houses our service.d script and (optionally) the Gadget.so.BEETROOT_FRIDA_SOCKET_PATH— Unix socket path for Gadget mode (unused when Gadget is disabled).BEETROOT_THREAD_NAME_SHIM— path to theLD_PRELOADshim from §3.3 (also lives inside the stealth module).
All five are read by T7's helper scripts already; v0.4 just wires the values in.
3.7 CI fingerprint audit¶
The drift problem from §1.4 says we can't ship a static mitigation list. The mitigation is automation: run an open-source root / Frida detector against the built image on every CI run, and fail the build if a new indicator appears that wasn't on a known-issues allowlist.
Candidate detectors (both run from an APK installed via ADB into the booted container):
- RootBeer — long-running Android library used by many production apps; covers Magisk, su binaries, dangerous props, RW system mounts.
- FridaAntiRootDetectionBypass test harness — a known-good Frida-detection test app, useful for sanity-checking that we also defeat the detection it implements.
A nightly job pulls the latest of each, installs into a freshly built Beetroot, and fails on new positives. Existing-and-accepted positives live in a YAML allowlist checked into the repo with a short rationale per entry — adding to the allowlist requires PR review just like any other source change.
4. Interaction with T7 (helper shell scripts)¶
T7 lands three helper shell scripts
that today live in docker/: magisk-config.sh, flash-modules.sh,
and launch-frida.sh. Each reads every container-side path from
BEETROOT_* env vars with safe defaults, so v0.4's path
randomization is a pure compose-template + env-var change with no
helper code edits required.
The full env-var contract:
| Env var | Default | Consumer |
|---|---|---|
BEETROOT_FRIDA_BIN |
/data/local/tmp/frida-server |
launch-frida.sh |
BEETROOT_FRIDA_SOCKET_PATH |
unset (TCP mode) | launch-frida.sh |
BEETROOT_MODULES_DIR |
/data/adb/modules_update |
flash-modules.sh |
BEETROOT_MAGISK_DB |
/data/adb/magisk.db |
magisk-config.sh |
BEETROOT_DENYLIST_PACKAGES |
com.google.android.gms,com.google.android.gms.unstable |
magisk-config.sh |
BEETROOT_STEALTH_MODULE_ID |
unset (legacy init.rc mode) | (build-time only) |
BEETROOT_THREAD_NAME_SHIM |
unset (no shim) | launch-frida.sh |
When BEETROOT_STEALTH_MODULE_ID is set, the build switches to
Magisk-module-mode (§3.4); when it's unset, the build keeps the
v0.3 init.rc layout. This gives a smooth migration path: §3.4 lands
behind an opt-in flag first, then becomes the default once the CI
fingerprint audit is green for both modes.
5. Interaction with T6 (snapshots)¶
T6 introduces a snapshot manifest — a machine-readable file alongside the data tarball that records what the snapshot is and how to restore it.
The manifest must carry the source instance's container-path mapping, otherwise a snapshot taken from a randomized-path source will restore against the destination's defaults and produce a device where Frida lives at one path but the data dump references another.
Concretely, the manifest gains:
path_layout:
frida_path: /data/adb/modules/x7q.../bin/svc
module_stage: /data/adb/modules_update
stealth_module_id: x7q4z2m1jhkr
frida_socket: /data/local/tmp/.x7q.sock # if Gadget mode
On beetroot restore, the CLI:
- Reads
path_layoutfrom the manifest. - Overwrites the target instance's randomized-paths registry blob with the manifest's values.
- Re-renders
.envso the compose template binds the same paths. - Starts the instance; everything inside
/data/now points where the snapshotted environment expects.
This makes snapshots portable across hosts and across Beetroot
versions as long as the schema doesn't change. A v0.4 → v0.6
snapshot migration would bump a path_layout_version field.
6. Interaction with T9 (device backends)¶
T9 introduces AdbDeviceBackend for running Beetroot's tooling
against a real rooted phone, not just the emulator. Several of the
mitigations in §3 don't apply to that backend, because we don't
control the device's filesystem or build:
| Mitigation | Emulator (default) | AdbDeviceBackend (T9) |
|---|---|---|
| §3.1 Frida path random. | Yes | Partial — randomize within /data/local/tmp/ only; can't write to /data/adb/modules/ without a user-installed Magisk module. |
| §3.2 Gadget via Zygisk | Yes | Yes, if user has Zygisk installed and lets us drop a module. Manual setup. |
| §3.3 Thread-name evasion | Yes | Yes — the LD_PRELOAD shim works wherever Frida runs. |
| §3.4 Magisk-module init | Yes | No. Device's init.rc is whatever the user's ROM ships; we never modify it. The user's existing Magisk install (if any) is what we have to work with. |
§3.5 modules_update/ |
Yes | N/A — we don't bind-mount anything on a real device. |
| §3.6 Build-time random. | Yes | No. "Build" doesn't exist for the device backend. Randomization happens once at beetroot create time and is then a property of the user's device install. |
| §3.7 CI fingerprint audit | Yes | No. CI doesn't have a real phone. Optional: opt-in nightly run against a researcher's spare device. |
The CLI surface is unchanged — researchers who switch from emulator
to device backend see the same beetroot frida-addr / beetroot apply
commands. The backend abstraction (T9) hides the
which-mitigations-apply detail behind feature flags on the backend
class.
7. v0.4 implementation roadmap¶
Ordered list of PRs that implement §3. Each PR cites the section it executes against. Complexity tags: S (≤1 day), M (2–3 days), L (≥1 week including review).
PR1 — Frida path randomization¶
- Status: DEFERRED to v0.6 pending stealth research. v0.4 lands
the plumbing only (the
BEETROOT_FRIDA_BINsubstitution is end-to-end through the bundled compose template,render_env, and the helper shells; thestealth_pathsregistry slot is in place). The default-path-flip is gated on the research prerequisite in §3.1. Once chosen, this becomes a one-line change inInstance.create'sstealth_pathsgenerator. - Scope (v0.6): Implement §3.1. Generate randomized
/data/adb/modules/<random>/bin/<random>(or whatever the research validates) atbeetroot create, persist in registry, wire into compose template viaBEETROOT_FRIDA_BIN. T7'slaunch-frida.shalready reads it. - Complexity: S (once research is in).
- Unblocks: The single highest-risk indicator from §2. Makes
/data/local/tmp/frida-serverscans miss us.
PR2 — Frida Gadget mode¶
- Scope: Implement §3.2. Add a
frida.mode: server | gadgetfield tobeetroot.yamlschema; ingadgetmode, replace the server bind-mount with a Zygisk module bind-mount under the randomized module dir from PR1; update the Frida transport (and whatbeetroot frida-addremits) to an ADB-forwarded Unix socket. - Complexity: L.
- Unblocks: Eliminates §1.1's binary, socket, and listener indicators in one move. Required for stealth research against modern GMS.
PR3 — Thread-name LD_PRELOAD shim¶
- Scope: Implement §3.3 step 1 (the shim, not the patched-Frida
alternative). 100-line C source built into a
.soat Docker build time; bundled into the stealth Magisk module from PR4; loaded viaLD_PRELOADinlaunch-frida.shwhenBEETROOT_THREAD_NAME_SHIMis set. - Complexity: M.
- Unblocks: Covers the residual
/proc/<pid>/task/<tid>/commhits left over from PR1+PR2.
PR4 — Stealth Magisk module replaces init.rc + entrypoint.sh¶
- Scope: Implement §3.4. Generate
module.propandservice.d/<random>.shat Docker build time with randomized IDs. Dropdocker/stealth.rcfromDockerfile's COPY list. Moveentrypoint.sh(and its T7 split) into the module'sservice.d/. Behind opt-in flagBEETROOT_STEALTH_MODULE_IDuntil CI is green. - Complexity: L.
- Unblocks: Eliminates §1.3's
/system/etc/init/stealth.rcand/entrypoint.shindicators. The biggest single drop in our Beetroot-specific surface.
PR5 — /flash_dir → /data/adb/modules_update/¶
- Status: DONE in v0.4 (T2 + T4). T2 shifted the bundled compose
template +
render_env'sBEETROOT_MODULES_DIRdefault; T4 fell through the same default indocker/flash-modules.sh's POSIX fallback. The/flash_dirdirectory is no longer created by the Dockerfile; existing v0.3 instances rebind on the nextdown + upcycle (no data loss —<instance-dir>/modules/is the host side and never moved). - Scope: Implement §3.5. Change
compose.yaml's bind-mount target via${BEETROOT_MODULES_DIR}. Update T7'sflash-modules.shdefault fallback. - Complexity: S.
- Unblocks: Removes the
flash_dirindicator from/proc/mounts.
PR6 — Stable randomized-path registry blob¶
- Status: DONE in v0.4 (T1 + T4) — plumbing only; defaults still
empty. T1's pydantic
RedroidBackendConfigintroducedstealth_paths: dict[str, str](defaults to{}). T4 wired the full round-trip:snapshot()reads the registry slot intoManifest.path_layout;restore()writesManifest.path_layoutback into the destination's registry slot via the newregistry.set_stealth_paths(name, blob)helper;_stage_localforwards the slot throughconfig.render_env(..., stealth_paths=...). v0.4 leaves the slot empty by default — v0.6's PR1 generator populates it once §3.1's research validates a path. - Scope: Implement §3.6. Add
stealth_paths: dict[str, str]to the registry entry. Generated atcreate, read byapply,up,down,snapshot,restore. T6 (PR-for-T6) consumesstealth_pathsfor snapshot manifests per §5. - Complexity: M.
- Unblocks: Makes PR1, PR4, PR5 deterministic across an
instance's lifetime — without this, every
applywould re-randomize paths and break the existing data dir.
PR7 — CI fingerprint audit¶
- Scope: Implement §3.7. New GitHub Actions job
.github/workflows/fingerprint-audit.yml: build image, boot via redroid, install RootBeer + the Frida-snippets test APK, scrape results, diff againstdocs/design/fingerprint-allowlist.yaml. - Complexity: L (mostly: getting redroid to boot in GHA is the hard part).
- Unblocks: §1.4's drift assumption. Without this, every GMS update is a manual triage exercise.
PR8 — Documentation pass¶
- Scope: Update Frida guide, Boot flow, Filesystem layout, and Snapshots guide to reflect the new paths and Gadget option. Move this doc out of "Design notes" once v0.4 ships and into a normal reference page.
- Complexity: S.
- Unblocks: The "docs are part of every feature" rule from
CLAUDE.md.
8. Out of scope (explicitly)¶
The following are real fingerprints that researchers will encounter, but they are not Beetroot's to fix:
- Kernel-level emulator artifacts.
/dev/socket/qemud,/dev/qemu_pipe,/sys/module/qemu_pipe, CPU feature flags (AT_HWCAPdifferences, missing AES-NI, etc.), missing or obviously-simulated sensors (SENSOR_TYPE_ACCELEROMETERreturning perfectly flat data forever). These are redroid's responsibility, not Beetroot's. v0.4 will document the residual list as a "known emulator tells" section but will not patch them. Researchers who need hardware-grade stealth should use the T9 device backend on a physical rooted phone. - Active Play Integrity attestation backed by hardware keys. Modern devices store an attestation key in the StrongBox / TEE that signs a server-issued nonce, proving that the boot chain hasn't been tampered with. No software-only approach in any research tool defeats this. Researchers who hit it have to switch to either an unpatched device (rare) or to a target that doesn't enforce hardware-backed attestation. This is an entirely separate research problem and v0.4 will not attempt to address it.
- Frida's network-level fingerprints when used with
beetroot fridaover a host port. A network observer between the host CLI and the container can see the Frida D-Bus handshake. This doesn't matter inside the container itself (the kernel is ours; loopback traffic isn't inspectable from a malicious app), but it matters for cross-host setups. v0.4 won't address it; v0.6 might via TLS-wrapped transport. - Compromised host kernels. If the host running Docker is compromised, the container's stealth posture is moot — the attacker has higher-privileged access than the app being studied. Out of scope by definition.