Skip to content

Commit 771dfa8

Browse files
committed
feat: Add fingerprint importer for parsing Android device properties
- Implemented a new module `importer.py` to parse raw `getprop` and `build.prop` outputs from Android devices. - Mapped parsed properties to `DeviceFingerprint` fields and created a mechanism for generating warnings for inconsistencies. - Added unit tests for consistency checks in `test_consistency.py`. feat: Create mitmproxy addon for capturing HTTP/HTTPS flows - Developed `mitm_addon.py` to capture and store HTTP/HTTPS flows as structured JSON. - Implemented flow record schema and methods for handling request and response data. test: Add unit tests for fingerprint consistency checks - Created comprehensive tests in `test_consistency.py` for various consistency checks including Luhn validation, build fingerprint format, MCC/MNC validation, and sensor completeness. feat: Implement FridaManager component for managing Frida sessions - Developed `FridaManager.tsx` to handle Frida process management, script injection, and user interactions. - Integrated API calls for attaching/detaching Frida sessions and pushing scripts. chore: Add script to run backend and frontend together for local development - Created `run_dev_all.sh` to streamline local development by running both backend and frontend servers concurrently.
1 parent 7583877 commit 771dfa8

24 files changed

Lines changed: 3536 additions & 37 deletions

README.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,128 @@ This release focuses on **Samsung-aligned fingerprints**, **clearer AVD hardware
213213

214214
---
215215

216+
### AVD spoofing depth & hard limits
217+
218+
The table below documents what **can** and **cannot** be changed on a stock Google AVD image. Understanding these limits helps set realistic expectations before deploying to production.
219+
220+
| Layer | Can spoof | Cannot fix on Google AVD |
221+
|-------|-----------|--------------------------|
222+
| `ro.product.*` props | ✅ via `setprop` (survives reboot only with root remount) | Samsung One UI behaviour, Knox APIs, SELinux policy |
223+
| Build fingerprint | ✅ string value | Trust chain — `ro.boot.verifiedbootstate` stays `orange` on most AVDs |
224+
| IMEI / Android ID | ✅ setprop + Frida hook | Baseband / modem (no real radio hardware) |
225+
| GPS location | ✅ emulator console `geo fix` | Real cell-tower / Wi-Fi positioning |
226+
| Battery & sensors | ✅ emulator console + Frida noise | Physical sensor hardware entropy |
227+
| Network type label | ✅ setprop label | Real carrier registration, actual SIM |
228+
| MAC address | ✅ setprop (Android 10+) | Hardware MAC at kernel level |
229+
| GLES / GPU | ❌ partial — `ro.hardware.egl` string only | Mesa/SwiftShader renderer; real Adreno/Mali unavailable |
230+
| `/proc/cpuinfo` | ❌ read-only inside QEMU | QEMU CPU model; not patchable without custom kernel |
231+
| `/dev/` device nodes || No real camera, barometer, NFC, fingerprint reader |
232+
| Samsung CSC / OMC | ✅ props only | System partition CSC packages absent |
233+
| Play Integrity || Requires hardware attestation TEE + valid chain |
234+
235+
**Frida scripts** under [`scripts/frida/`](scripts/frida/) can extend runtime spoofing (Java APIs, `TelephonyManager`, `SensorManager`, `Build` class) and partially compensate for some ❌ items above — at the cost of requiring a rooted/debuggable image and a running Frida server.
236+
237+
---
238+
239+
### Fingerprint model: one fingerprint + revisions per device
240+
241+
Each device has **exactly one active fingerprint** at a time. Changes are non-destructive:
242+
243+
- Every write (PUT, randomize, revert, import) **saves a revision snapshot** first.
244+
- `GET /api/fingerprint/{id}/revisions` — list up to 30 revisions.
245+
- `POST /api/fingerprint/{id}/revert/{revision_id}` — restore any snapshot.
246+
- `GET /api/fingerprint/{id}/compare?revision_id={id}` — field-level diff.
247+
248+
Multi-profile support (multiple named fingerprints per device, switch between them) is **not yet implemented**. The current data model is `device ↔ 1 DeviceFingerprint + N FingerprintRevision`. If needed, design a `device_fingerprint_profiles` table and `device.active_fingerprint_id` FK (tracked in the backlog).
249+
250+
#### Import from a real device
251+
252+
```bash
253+
# Dump props from a real phone:
254+
adb -s <serial> shell getprop > my_phone.props
255+
256+
# Import via API:
257+
curl -X POST “http://localhost:8000/api/fingerprint/{device_id}/import” \
258+
-H “Authorization: Bearer $TOKEN” \
259+
-H “Content-Type: application/json” \
260+
-d '{“text”: “'$(cat my_phone.props | python3 -c “import sys,json; print(json.dumps(sys.stdin.read()))”)'”, “revision_label”: “real_device_import”}'
261+
```
262+
263+
Known props are mapped automatically; the response includes `import_warnings` for any mismatches.
264+
265+
#### Periodic jitter (IP / GPS / battery)
266+
267+
```bash
268+
# Randomise GPS + IP with a small delta, apply live:
269+
curl -X POST “http://localhost:8000/api/fingerprint/{id}/jitter” \
270+
-H “Authorization: Bearer $TOKEN” \
271+
-H “Content-Type: application/json” \
272+
-d '{“fields”: [“ip_address”, “latitude”, “longitude”], “apply”: true}'
273+
```
274+
275+
Combine with a cron job or APScheduler task for automated rotation.
276+
277+
---
278+
279+
### H.264 live stream
280+
281+
#### When to enable
282+
283+
H.264 replaces the default JPEG screencap path. Use it when:
284+
- You need **< 100 ms** frame latency (JPEG path: 200–600 ms).
285+
- The device is **running Android 5+** with `screenrecord` available.
286+
- The server has sufficient CPU for `screenrecord` H.264 encoding (≈ 1 core per active stream).
287+
288+
#### Requirements
289+
290+
| Requirement | Notes |
291+
|-------------|-------|
292+
| Android API level | ≥ 21 (screenrecord --output-format=h264) |
293+
| ADB | Connected and authorized |
294+
| Server CPU | ~1 core per active H.264 stream |
295+
| Browser | Chrome / Edge (WebCodecs VideoDecoder); Firefox partial |
296+
297+
#### WebSocket endpoint
298+
299+
```
300+
ws://<host>/ws/{device_id}/h264?token=<JWT>
301+
```
302+
303+
**Binary frames (server → browser):**
304+
305+
| Byte 0 | Contents |
306+
|--------|----------|
307+
| `0x01` CONFIG | codec string + SPS + PPS — initialize VideoDecoder |
308+
| `0x02` FRAME | keyframe flag (1 byte) + PTS µs (8 bytes LE) + H.264 NAL data |
309+
310+
**JSON frames (browser → server):** same protocol as `/ws/{device_id}``tap`, `swipe`, `keyevent`, `input_text`, `ping`.
311+
312+
#### Touch coordinate mapping
313+
314+
The H.264 canvas is rendered at a CSS display size that differs from the encoded frame resolution (default 720×1280). The frontend hook [`useDeviceH264.ts`](frontend/src/hooks/useDeviceH264.ts) exposes `frameWidth` / `frameHeight`; the touch layer in [`DeviceScreen.tsx`](frontend/src/components/DeviceScreen.tsx) maps pointer events through `clientToDeviceSurface()` using the **actual frame dimensions**, not the canvas CSS size. This ensures taps land at the correct device pixel regardless of zoom/fullscreen state.
315+
316+
#### JPEG fallback
317+
318+
- If no CONFIG frame arrives within **22 seconds**, `useDeviceH264` calls `onGiveUp()` and the UI reverts to JPEG mode automatically.
319+
- You can also switch manually via the stream-mode toggle in the device screen.
320+
321+
#### Multi-device limits
322+
323+
Running H.264 for many devices simultaneously multiplies CPU and bandwidth load. Rule of thumb: **2–4 concurrent streams** on a 4-core server at 720p/30 fps. For larger farms, consider:
324+
- Reducing resolution/fps (configurable in `ws_h264.py``stream_task` call).
325+
- Keeping JPEG mode for idle/background devices.
326+
- V2 Pro gRPC path for production-grade concurrency.
327+
328+
---
329+
330+
### Security & compliance
331+
332+
> **Important:** This project is designed for testing, QA, and research on **devices you own or are authorised to test**. Spoofing device identifiers without the device owner's consent, or using this system to circumvent third-party platform policies (app stores, payment systems, fraud detection), may violate those platforms' terms of service and local law. The maintainer does not encourage or condone unauthorised use.
333+
334+
See [`docs/REFERENCE_SPEC.md`](docs/REFERENCE_SPEC.md) for the full endpoint reference.
335+
336+
---
337+
216338
### Tech stack
217339
218340
| Layer | Stack |
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""
2+
Live device control endpoints — GPS, battery, network type, proxy.
3+
4+
These apply instantly to a running emulator via the emulator console (telnet)
5+
or ADB shell, without touching the stored fingerprint. To persist a change,
6+
use PUT /api/fingerprint/{id} afterwards.
7+
8+
Routes
9+
------
10+
POST /api/devices/{id}/gps — set GPS coordinates live
11+
POST /api/devices/{id}/battery — set battery level + charging state live
12+
POST /api/devices/{id}/network-type — set simulated network type live
13+
POST /api/devices/{id}/proxy/apply — re-apply current proxy settings to device
14+
"""
15+
from __future__ import annotations
16+
17+
import logging
18+
from typing import Optional
19+
20+
from fastapi import APIRouter, Depends, HTTPException
21+
from pydantic import BaseModel, Field
22+
from sqlalchemy import select
23+
from sqlalchemy.ext.asyncio import AsyncSession
24+
25+
from api.deps import get_db, get_current_user
26+
from db.models import Device, DeviceStatus, User
27+
from core.fingerprint.spoofer import FingerprintSpoofer
28+
from core.tools.adb import ADBTool
29+
30+
router = APIRouter(prefix="/devices", tags=["device-controls"])
31+
adb_tool = ADBTool()
32+
spoofer = FingerprintSpoofer()
33+
logger = logging.getLogger(__name__)
34+
35+
36+
# ---------------------------------------------------------------------------
37+
# Helpers
38+
# ---------------------------------------------------------------------------
39+
40+
async def _running_device(device_id: int, db: AsyncSession, user: User) -> Device:
41+
r = await db.execute(select(Device).where(Device.id == device_id))
42+
device = r.scalar_one_or_none()
43+
if not device:
44+
raise HTTPException(404, "Device not found")
45+
if device.owner_id != user.id and user.role.value != "admin":
46+
raise HTTPException(403, "Access denied")
47+
if device.status != DeviceStatus.running or not device.adb_serial:
48+
raise HTTPException(400, "Device must be running with ADB serial")
49+
return device
50+
51+
52+
# ---------------------------------------------------------------------------
53+
# GPS
54+
# ---------------------------------------------------------------------------
55+
56+
class GpsBody(BaseModel):
57+
latitude: float = Field(..., ge=-90, le=90)
58+
longitude: float = Field(..., ge=-180, le=180)
59+
altitude: float = Field(0.0)
60+
accuracy: float = Field(12.0, ge=0)
61+
persist: bool = Field(
62+
False,
63+
description="If true, also update the stored fingerprint latitude/longitude/altitude",
64+
)
65+
66+
67+
@router.post("/{device_id}/gps")
68+
async def set_gps_live(
69+
device_id: int,
70+
body: GpsBody,
71+
db: AsyncSession = Depends(get_db),
72+
user: User = Depends(get_current_user),
73+
):
74+
"""
75+
Set the device GPS location instantly via the emulator console (`geo fix`).
76+
77+
Works on Google AVD only (requires `console_port`). Does NOT require root.
78+
The coordinate is applied at the emulator-hardware level, so all apps that
79+
read GPS via `LocationManager` will see it immediately.
80+
81+
Pass ``persist=true`` to also update the fingerprint row in the database.
82+
"""
83+
device = await _running_device(device_id, db, user)
84+
if not device.console_port:
85+
raise HTTPException(400, "Device has no console_port — GPS control requires AVD emulator")
86+
87+
ok = await spoofer._telnet_command(
88+
device.console_port,
89+
f"geo fix {body.longitude} {body.latitude} {body.altitude}",
90+
)
91+
if not ok:
92+
raise HTTPException(502, "geo fix command failed — check emulator console connectivity")
93+
94+
if body.persist:
95+
from db.models import DeviceFingerprint
96+
r2 = await db.execute(select(DeviceFingerprint).where(DeviceFingerprint.device_id == device_id))
97+
fp = r2.scalar_one_or_none()
98+
if fp:
99+
fp.latitude = body.latitude
100+
fp.longitude = body.longitude
101+
fp.altitude = body.altitude
102+
await db.flush()
103+
104+
return {
105+
"success": True,
106+
"applied": f"geo fix {body.longitude} {body.latitude} {body.altitude}",
107+
"persisted": body.persist,
108+
}
109+
110+
111+
# ---------------------------------------------------------------------------
112+
# Battery
113+
# ---------------------------------------------------------------------------
114+
115+
class BatteryBody(BaseModel):
116+
level: int = Field(..., ge=0, le=100, description="Battery percentage 0–100")
117+
charging: bool = Field(False, description="True = AC charging; False = discharging")
118+
temperature_celsius: float = Field(32.0, ge=0, le=60)
119+
120+
121+
@router.post("/{device_id}/battery")
122+
async def set_battery_live(
123+
device_id: int,
124+
body: BatteryBody,
125+
db: AsyncSession = Depends(get_db),
126+
user: User = Depends(get_current_user),
127+
):
128+
"""
129+
Set battery level, charging state, and temperature via emulator console.
130+
"""
131+
device = await _running_device(device_id, db, user)
132+
if not device.console_port:
133+
raise HTTPException(400, "Device has no console_port")
134+
135+
cmds = [
136+
f"power capacity {body.level}",
137+
"power status ac" if body.charging else "power status discharging",
138+
f"power health good",
139+
f"power temp {int(body.temperature_celsius * 10)}",
140+
]
141+
applied = []
142+
failed = []
143+
for cmd in cmds:
144+
ok = await spoofer._telnet_command(device.console_port, cmd)
145+
(applied if ok else failed).append(cmd)
146+
147+
return {
148+
"success": len(failed) == 0,
149+
"applied": applied,
150+
"failed": failed,
151+
}
152+
153+
154+
# ---------------------------------------------------------------------------
155+
# Network type
156+
# ---------------------------------------------------------------------------
157+
158+
_NETWORK_SPEED_MAP = {
159+
"WIFI": "full",
160+
"LTE": "lte",
161+
"5G": "5g",
162+
"3G": "umts",
163+
"2G": "gprs",
164+
"EDGE": "edge",
165+
"HSPA": "hsdpa",
166+
"NONE": "full",
167+
}
168+
169+
170+
class NetworkTypeBody(BaseModel):
171+
network_type: str = Field(
172+
...,
173+
description="One of: WIFI, LTE, 5G, 3G, 2G, EDGE, HSPA",
174+
)
175+
persist: bool = False
176+
177+
178+
@router.post("/{device_id}/network-type")
179+
async def set_network_type_live(
180+
device_id: int,
181+
body: NetworkTypeBody,
182+
db: AsyncSession = Depends(get_db),
183+
user: User = Depends(get_current_user),
184+
):
185+
"""
186+
Change the simulated network type/speed via emulator console.
187+
Also updates ``ro.telephony.default_network_type`` via setprop for label spoofing.
188+
"""
189+
device = await _running_device(device_id, db, user)
190+
nt = body.network_type.upper()
191+
if nt not in _NETWORK_SPEED_MAP:
192+
raise HTTPException(400, f"Unknown network_type {nt!r}. Valid: {sorted(_NETWORK_SPEED_MAP)}")
193+
194+
results: dict = {"applied": [], "failed": []}
195+
196+
if device.console_port:
197+
speed = _NETWORK_SPEED_MAP[nt]
198+
ok = await spoofer._telnet_command(device.console_port, f"network speed {speed}")
199+
(results["applied"] if ok else results["failed"]).append(f"network speed {speed}")
200+
201+
# Also spoof the label via setprop
202+
label_map = {"WIFI": "1", "LTE": "11", "5G": "20", "3G": "3", "2G": "1"}
203+
label = label_map.get(nt, "11")
204+
try:
205+
await adb_tool.shell(device.adb_serial, f"setprop ro.telephony.default_network_type {label}")
206+
results["applied"].append(f"setprop ro.telephony.default_network_type={label}")
207+
except Exception as exc:
208+
results["failed"].append(f"setprop: {exc}")
209+
210+
if body.persist:
211+
from db.models import DeviceFingerprint
212+
r2 = await db.execute(select(DeviceFingerprint).where(DeviceFingerprint.device_id == device_id))
213+
fp = r2.scalar_one_or_none()
214+
if fp:
215+
fp.network_type = nt
216+
await db.flush()
217+
218+
return {"success": len(results["failed"]) == 0, **results, "persisted": body.persist}

0 commit comments

Comments
 (0)