> blog

Logo

Cybersecurity engineer, bug hunter, and security researcher.

Website · GitHub · Email · PGP

← All posts

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 identityservicesd to 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:

  1. 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.
  2. 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.
  3. Private UI behavior replaced a missing public refresh API. Injected code drove the same People-detail transitions a user could perform manually.
  4. 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.