How I hacked my iPhone to track my family
I started with a simple goal: one private dashboard for the locations my family had already shared with me, my own Apple devices, and Find My accessories such as my bike and keys, I also wanted history, playback, and alerts instead of a map that only showed the latest point.
Apple does not provide a supported API for building that dashboard, so my solution was to turn an old iPhone 6s running ios 15.8.8 into a dedicated Find My data node, understand the encrypted records that ios stored locally, and forward only normalized locations to my own server.
Every device, account, and People share used for this project was mine or my family
The system I was trying to build
At first, the architecture was simple: keep Find My open on the iPhone, read its cache over ssh, decrypt each record on my server, and send the result to a Flask application.
Find My relay network
|
v
jailbroken iPhone
searchpartyd cache
|
| key-based SSH
v
Python poller
decrypt + normalize
|
| authenticated local API
v
Flask + SQLite dashboard
That diagram hides an important detail, find My is not one location system so I eventually had to treat three independent sources differently:
- FMIP returns direct GPS positions for my own online Apple devices.
- Find My Network accessories depend on nearby Apple devices hearing their Bluetooth advertisements and relaying encrypted reports.
- People sharing uses its own end-to-end encrypted Secure Location records.
Confusing these sources creates fake freshness like a response received “now” does not mean its embedded location was measured now, so my dashboard always keeps the timestamp from the decrypted report.
Getting below the Find My interface
on a normal iphone, app sandboxing and data protection prevent an arbitrary
process from reading another service’s private data, a rootless jailbreak let
me inspect the state maintained by searchpartyd, Apple’s Find My background
daemon.
The useful directories on the tested version were:
/var/mobile/Library/com.apple.icloud.searchpartyd/
├── OwnedBeacons/
├── BeaconEstimatedLocation/
├── BeaconNamingRecord/
├── BeaconObservationStore/observations.plist
├── SecureLocationCache/
└── SecureLocationSharedKeys/
BeaconEstimatedLocation contained relayed positions for accessories.
SecureLocationCache contained positions shared through the People tab. The
observation store described accessories recently heard over Bluetooth, but a
local observation was not automatically a location report.
This distinction mattered for the bike. If another Apple device encountered it, a new encrypted coordinate could arrive even while my node was somewhere else. If nobody relayed a report, no command on my iPhone could manufacture a real coordinate.
Decrypting the iOS 15 cache
The .record files were binary property lists. On iOS 15.8.8, the outer value
was an array of three byte strings:
[nonce, authentication tag, ciphertext]
They were protected with AES-256-GCM and the BeaconStore master key held by
the authorized device. On this phone, that Keychain material was unavailable
until the first unlock after a reboot. This is why a restarted node could be
reachable over the network but still fail to produce usable Find My data.
The decryption itself was small once the format was known:
wrapper = plistlib.loads(record_bytes)
nonce, tag, ciphertext = wrapper
plaintext = AESGCM(master_key).decrypt(
nonce,
ciphertext + tag,
None,
)
record = plistlib.loads(plaintext)
The order is easy to get wrong. Python’s AESGCM.decrypt expects the
authentication tag appended to the ciphertext, although the Apple wrapper
stored the tag as the second array element.
I deliberately kept key extraction version-specific and out of the generic
tool. The master key can decrypt private accessory material as well as cached
locations. It belongs in a mode-0600 service environment file, never in a
command argument, article, screenshot, or source repository.
Turning cache access into a reliable poller
My first experiments repeatedly opened new SSH connections and copied files every few seconds. They worked, but they also wasted CPU and contributed to the heat generated by a permanently connected old phone.
The production poller uses one persistent SSH control connection. On each cycle, it streams only the two required cache trees as a tar archive, decrypts the records in memory, normalizes their timestamps and coordinates, and posts the newest points to the dashboard.
Each source gets an explicit label:
offline_network accessory relay report
secure_location People record from the iPhone cache
icloud_gps direct location of my own Apple device
nearby_iphone optional, explicitly inferred position
secure_location_linux People report decrypted by the later Linux client
The optional nearby_iphone source deserves caution. If the stationary node
hears a known accessory over Bluetooth, it can infer that the accessory is near
the node and reuse the node’s coordinate. That is not an Apple relay report. I
disabled this inference by default and label it clearly whenever it is enabled.
The poller writes its local JSON snapshot atomically and pushes updates through an API key. The ingestion layer rejects duplicate device-and-timestamp pairs before inserting into SQLite, preserving the original measurement time instead of generating a fresh point on every poll.
The difficult part was forcing fresh data
Reading the cache was only half the problem. Find My did not always refresh Items and People in the same way.
Opening the findmy:// URL woke the application, and a private Darwin
notification helped trigger accessory activity. It did not reliably update
SecureLocationCache for People. An endpoint historically associated with
fmf/refreshClient also returned my own FMIP device objects during testing,
not the People positions I needed.
I then tried killing and reopening Find My very frequently. That made things worse: the map became black, input froze, and scene updates hung. The stable pattern was almost the opposite—leave Find My in the foreground, request the specific updates I needed, and perform one clean relaunch per hour.
Refreshing every shared person
The useful observation was that opening one person’s detail screen started a
real live-location request through FMFCore. I built a small Objective-C
dynamic library and injected it into my own Find My process. It locates the
People table, selects each visible row eight seconds apart, and returns to the
People list when the cycle finishes.
In simplified form, the core behavior was:
NSIndexPath *path = [NSIndexPath indexPathForRow:row inSection:section];
[table selectRowAtIndexPath:path
animated:NO
scrollPosition:UITableViewScrollPositionNone];
if ([delegate respondsToSelector:
@selector(tableView:didSelectRowAtIndexPath:)]) {
[delegate tableView:table didSelectRowAtIndexPath:path];
}
The helper listens for a private Darwin notification, ignores overlapping requests, and cycles through the rows every 15 minutes. Because it counts the current UI rows rather than using a hard-coded allowlist, a newly accepted People share is discovered automatically. It still cannot accept a share or bypass the other person’s permission.
Once per hour, a supervised launch job closes Find My, starts a clean process, injects the refresher, runs one People cycle, and leaves the People tab in the foreground. This bounded the stale UI state without constantly destroying the application.
Heat, hangs, and unexpected reboots
Keeping an old jailbroken phone powered and network-active indefinitely exposed problems that a short proof of concept would never reveal.
A permanent no-sleep assertion, aggressive cache polling, and repeated SSH handshakes generated unnecessary load. I reduced polling, reused the SSH connection, added charge-limit hysteresis, and backed off activity when battery temperature or system load became excessive.
I also added two supervised maintenance jobs:
- a stability guard pauses Find My after sustained critical load, temperature, or low free-memory conditions and restores it after a cooldown;
- an IDS maintenance job periodically recycles
identityservicesdto limit the lifetime of its networking resources.
The second control came from evidence, not guesswork. One captured reboot was
an AppleTriStar2 USB-controller panic. Another, after roughly 45 hours of
uptime, was a Skywalk mandatory-allocation failure in an IDS channel, with
identityservicesd as the panicked task. Neither was a thermal shutdown.
This also changed how I described reliability. No script can promise that a jailbroken phone will “never restart” after a kernel or hardware panic. The responsible goal is to reduce avoidable pressure, retain panic evidence, and recover cleanly after the user performs the first unlock.
Removing the iPhone dependency for People
The iPhone cache path was reliable for accessories, but People did not fundamentally need to remain tied to it. Instead of trying to make the iPhone more reliable forever, I reproduced the parts of the People flow that the server actually needed.
The first step was creating a separate Apple client identity on Linux. I signed in to my Apple account through the GrandSlam authentication flow and completed 2FA during setup. The server then activated an APNs identity, which gave it a push certificate, private key, and token. After that, it registered an IDS identity for the Find My multiplex service, including its own device UUID, account handles, signing certificate, and message-encryption keys.
All of this material is sensitive. The account session, APNs and IDS private keys, certificates, and People share keys are stored together in an encrypted state file. A separate 32-byte wrapping key protects that file and is kept outside the repository with restrictive permissions.
Registering the Linux identity was not enough by itself. A People location is encrypted for an accepted share, so the server also needed that share’s private key and advertised identifier. For the initial migration, I seeded the valid keys from the trusted cache on my authorized iPhone. Four complete shares were accepted. Another cache entry had no private key, so the importer rejected it instead of creating a device that could never decrypt anything.
The Linux process can also request key distribution for existing authorized
shares. It initializes the FMF client state, selects each shared person, and
sends a proactive distributeKeys request through SearchParty. A long-running
APNs receiver waits for the replies. When a key message arrives, it checks that
the message targets my account handle, resolves the sender through the IDS
directory, verifies the sender’s signature, and only then decrypts the
pair-ec envelope and saves the delivered key. This receiver is what allows
future accepted shares to become available without copying the iPhone cache
again.
Once a key is present, getting a location no longer involves the phone. The
poller restores the encrypted account and APNs state, initializes its FMF
context, and sends a startLocationUpdates request for each accepted share.
The request includes that share’s advertised identifier. Apple returns an
encrypted location payload, which is authenticated and decrypted locally on
my server.
For each accepted share, the key blob contains an uncompressed 57-byte P-224 public point followed by a 28-byte private scalar. A location report begins with an ephemeral P-224 public point. The client validates the stored key, performs ECDH, derives 32 bytes with X9.63/SHA-256, and uses the first and second 16-byte halves as the AES-128-GCM key and nonce.
secret = private_key.exchange(ec.ECDH(), ephemeral_public_key)
material = X963KDF(
algorithm=hashes.SHA256(),
length=32,
sharedinfo=ephemeral_public_bytes,
).derive(secret)
plaintext = AESGCM(material[:16]).decrypt(
material[16:32],
encrypted_report,
None,
)
This was a protocol implementation, not an encryption bypass. The server could decrypt a report only because the signed-in account had already received the private key for an authorized share.
I split the runtime into two supervised services. One keeps the APNs connection
open for share-key deliveries. The other polls every minute, decrypts the most
recent report for each person, normalizes the coordinates and original Apple
timestamp, and sends it to the dashboard as secure_location_linux. Both
services start automatically and retry with backoff instead of entering a
tight crash loop when Apple or the network is temporarily unavailable.
There was one final server-side detail. The dashboard originally read its live
map only from locations.json, a file written by the iPhone poller. The Linux
process was correctly adding new People points to SQLite, but the map could
still display the old iPhone snapshot. I changed the device endpoint to merge
the newest database row into the live view. That made the fresh Linux location
visible instead of merely storing it in history.
After this migration, the iPhone could be powered off and People would continue
to update. Accessories could not: the bike and keys still depended on the
iPhone’s searchpartyd cache and Apple’s Find My Network relay reports.
What I actually exploited
The project crossed several boundaries, but describing them accurately is important:
- The jailbreak crossed the normal iOS sandbox. It gave me privileged access to files and processes on a device I controlled. The project did not discover or ship the jailbreak exploit itself.
- The on-device cache exposed a useful integration point. Find My had to decrypt data for its own UI, so authenticated records and key material existed on the authorized endpoint. I reproduced that local decryption outside the app.
- Private UI behavior replaced a missing public refresh API. Injected code drove the same People-detail transitions a user could perform manually.
- Private Apple protocols replaced the iPhone for People. The Linux client acted as an authenticated IDS/APNs endpoint and processed only keys sent to the signed-in account.
What I learned
The most useful lesson was not a cryptographic trick. It was learning to stop treating Find My as one opaque application.
Items, People, and personal devices have different producers, refresh paths, timestamps, and failure modes. Once I separated them, the strange behavior made sense: a Bluetooth observation was not a relay location, an API response was not proof of a new measurement, and an app relaunch was not a substitute for a People live request.
The old iPhone remains useful as an accessory cache node. The Linux identity now handles People without it. The dashboard combines both paths while preserving where every point came from—and, just as importantly, what each point does not prove.