<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://blog.feth-ellah.fr/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.feth-ellah.fr/" rel="alternate" type="text/html" /><updated>2026-09-06T01:49:34+02:00</updated><id>https://blog.feth-ellah.fr/feed.xml</id><title type="html">blog.feth-ellah.fr</title><subtitle>Security research, write-ups, and technical notes by Fetheallah Boudellal.</subtitle><author><name>Fetheallah Boudellal</name><email>feth-ellah@pm.me</email></author><entry><title type="html">How I hacked my iPhone to track my family</title><link href="https://blog.feth-ellah.fr/how-i-hacked-my-iphone-to-track-my-family/" rel="alternate" type="text/html" title="How I hacked my iPhone to track my family" /><published>2026-09-06T00:00:00+02:00</published><updated>2026-09-06T00:00:00+02:00</updated><id>https://blog.feth-ellah.fr/how-i-hacked-my-iphone-to-track-my-family</id><content type="html" xml:base="https://blog.feth-ellah.fr/how-i-hacked-my-iphone-to-track-my-family/"><![CDATA[<p>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.</p>

<p>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.</p>

<blockquote>
  <p>Every device, account, and People share used for this project was mine 
or my family</p>
</blockquote>

<h2 id="the-system-i-was-trying-to-build">The system I was trying to build</h2>

<p>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.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Find My relay network
        |
        v
jailbroken iPhone
  searchpartyd cache
        |
        | key-based SSH
        v
Python poller
  decrypt + normalize
        |
        | authenticated local API
        v
Flask + SQLite dashboard
</code></pre></div></div>

<p>That diagram hides an important detail, find My is not one location system so I
eventually had to treat three independent sources differently:</p>

<ul>
  <li>FMIP returns direct GPS positions for my own online Apple devices.</li>
  <li>Find My Network accessories depend on nearby Apple devices hearing their
Bluetooth advertisements and relaying encrypted reports.</li>
  <li>People sharing uses its own end-to-end encrypted Secure Location records.</li>
</ul>

<p>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.</p>

<h2 id="getting-below-the-find-my-interface">Getting below the Find My interface</h2>

<p>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 <code class="language-plaintext highlighter-rouge">searchpartyd</code>, Apple’s Find My background
daemon.</p>

<p>The useful directories on the tested version were:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/var/mobile/Library/com.apple.icloud.searchpartyd/
├── OwnedBeacons/
├── BeaconEstimatedLocation/
├── BeaconNamingRecord/
├── BeaconObservationStore/observations.plist
├── SecureLocationCache/
└── SecureLocationSharedKeys/
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">BeaconEstimatedLocation</code> contained relayed positions for accessories.
<code class="language-plaintext highlighter-rouge">SecureLocationCache</code> 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.</p>

<p>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.</p>

<h2 id="decrypting-the-ios-15-cache">Decrypting the iOS 15 cache</h2>

<p>The <code class="language-plaintext highlighter-rouge">.record</code> files were binary property lists. On iOS 15.8.8, the outer value
was an array of three byte strings:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[nonce, authentication tag, ciphertext]
</code></pre></div></div>

<p>They were protected with AES-256-GCM and the <code class="language-plaintext highlighter-rouge">BeaconStore</code> 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.</p>

<p>The decryption itself was small once the format was known:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">wrapper</span> <span class="o">=</span> <span class="n">plistlib</span><span class="p">.</span><span class="n">loads</span><span class="p">(</span><span class="n">record_bytes</span><span class="p">)</span>
<span class="n">nonce</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">ciphertext</span> <span class="o">=</span> <span class="n">wrapper</span>

<span class="n">plaintext</span> <span class="o">=</span> <span class="n">AESGCM</span><span class="p">(</span><span class="n">master_key</span><span class="p">).</span><span class="n">decrypt</span><span class="p">(</span>
    <span class="n">nonce</span><span class="p">,</span>
    <span class="n">ciphertext</span> <span class="o">+</span> <span class="n">tag</span><span class="p">,</span>
    <span class="bp">None</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">record</span> <span class="o">=</span> <span class="n">plistlib</span><span class="p">.</span><span class="n">loads</span><span class="p">(</span><span class="n">plaintext</span><span class="p">)</span>
</code></pre></div></div>

<p>The order is easy to get wrong. Python’s <code class="language-plaintext highlighter-rouge">AESGCM.decrypt</code> expects the
authentication tag appended to the ciphertext, although the Apple wrapper
stored the tag as the second array element.</p>

<p>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-<code class="language-plaintext highlighter-rouge">0600</code> service environment file, never in a
command argument, article, screenshot, or source repository.</p>

<h2 id="turning-cache-access-into-a-reliable-poller">Turning cache access into a reliable poller</h2>

<p>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.</p>

<p>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.</p>

<p>Each source gets an explicit label:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>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
</code></pre></div></div>

<p>The optional <code class="language-plaintext highlighter-rouge">nearby_iphone</code> 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.</p>

<p>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.</p>

<h2 id="the-difficult-part-was-forcing-fresh-data">The difficult part was forcing fresh data</h2>

<p>Reading the cache was only half the problem. Find My did not always refresh
Items and People in the same way.</p>

<p>Opening the <code class="language-plaintext highlighter-rouge">findmy://</code> URL woke the application, and a private Darwin
notification helped trigger accessory activity. It did not reliably update
<code class="language-plaintext highlighter-rouge">SecureLocationCache</code> for People. An endpoint historically associated with
<code class="language-plaintext highlighter-rouge">fmf/refreshClient</code> also returned my own FMIP device objects during testing,
not the People positions I needed.</p>

<p>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.</p>

<h3 id="refreshing-every-shared-person">Refreshing every shared person</h3>

<p>The useful observation was that opening one person’s detail screen started a
real live-location request through <code class="language-plaintext highlighter-rouge">FMFCore</code>. 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.</p>

<p>In simplified form, the core behavior was:</p>

<pre><code class="language-objective-c">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];
}
</code></pre>

<p>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.</p>

<p>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.</p>

<h2 id="heat-hangs-and-unexpected-reboots">Heat, hangs, and unexpected reboots</h2>

<p>Keeping an old jailbroken phone powered and network-active indefinitely exposed
problems that a short proof of concept would never reveal.</p>

<p>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.</p>

<p>I also added two supervised maintenance jobs:</p>

<ul>
  <li>a stability guard pauses Find My after sustained critical load, temperature,
or low free-memory conditions and restores it after a cooldown;</li>
  <li>an IDS maintenance job periodically recycles <code class="language-plaintext highlighter-rouge">identityservicesd</code> to limit the
lifetime of its networking resources.</li>
</ul>

<p>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
<code class="language-plaintext highlighter-rouge">identityservicesd</code> as the panicked task. Neither was a thermal shutdown.</p>

<p>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.</p>

<h2 id="removing-the-iphone-dependency-for-people">Removing the iPhone dependency for People</h2>

<p>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.</p>

<p>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.</p>

<p>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.</p>

<p>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.</p>

<p>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 <code class="language-plaintext highlighter-rouge">distributeKeys</code> 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
<code class="language-plaintext highlighter-rouge">pair-ec</code> envelope and saves the delivered key. This receiver is what allows
future accepted shares to become available without copying the iPhone cache
again.</p>

<p>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 <code class="language-plaintext highlighter-rouge">startLocationUpdates</code> 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.</p>

<p>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.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">secret</span> <span class="o">=</span> <span class="n">private_key</span><span class="p">.</span><span class="n">exchange</span><span class="p">(</span><span class="n">ec</span><span class="p">.</span><span class="n">ECDH</span><span class="p">(),</span> <span class="n">ephemeral_public_key</span><span class="p">)</span>
<span class="n">material</span> <span class="o">=</span> <span class="n">X963KDF</span><span class="p">(</span>
    <span class="n">algorithm</span><span class="o">=</span><span class="n">hashes</span><span class="p">.</span><span class="n">SHA256</span><span class="p">(),</span>
    <span class="n">length</span><span class="o">=</span><span class="mi">32</span><span class="p">,</span>
    <span class="n">sharedinfo</span><span class="o">=</span><span class="n">ephemeral_public_bytes</span><span class="p">,</span>
<span class="p">).</span><span class="n">derive</span><span class="p">(</span><span class="n">secret</span><span class="p">)</span>

<span class="n">plaintext</span> <span class="o">=</span> <span class="n">AESGCM</span><span class="p">(</span><span class="n">material</span><span class="p">[:</span><span class="mi">16</span><span class="p">]).</span><span class="n">decrypt</span><span class="p">(</span>
    <span class="n">material</span><span class="p">[</span><span class="mi">16</span><span class="p">:</span><span class="mi">32</span><span class="p">],</span>
    <span class="n">encrypted_report</span><span class="p">,</span>
    <span class="bp">None</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>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.</p>

<p>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 <code class="language-plaintext highlighter-rouge">secure_location_linux</code>. Both
services start automatically and retry with backoff instead of entering a
tight crash loop when Apple or the network is temporarily unavailable.</p>

<p>There was one final server-side detail. The dashboard originally read its live
map only from <code class="language-plaintext highlighter-rouge">locations.json</code>, 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.</p>

<p>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 <code class="language-plaintext highlighter-rouge">searchpartyd</code> cache and Apple’s Find My Network relay reports.</p>

<h2 id="what-i-actually-exploited">What I actually exploited</h2>

<p>The project crossed several boundaries, but describing them accurately is
important:</p>

<ol>
  <li><strong>The jailbreak crossed the normal iOS sandbox.</strong> 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.</li>
  <li><strong>The on-device cache exposed a useful integration point.</strong> 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.</li>
  <li><strong>Private UI behavior replaced a missing public refresh API.</strong> Injected code
drove the same People-detail transitions a user could perform manually.</li>
  <li><strong>Private Apple protocols replaced the iPhone for People.</strong> The Linux client
acted as an authenticated IDS/APNs endpoint and processed only keys sent to
the signed-in account.</li>
</ol>

<h2 id="what-i-learned">What I learned</h2>

<p>The most useful lesson was not a cryptographic trick. It was learning to stop
treating Find My as one opaque application.</p>

<p>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.</p>

<p>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.</p>]]></content><author><name>Fetheallah Boudellal</name><email>feth-ellah@pm.me</email></author><summary type="html"><![CDATA[How I turned a jailbroken iPhone into a private Find My node by decrypting local caches, automating refreshes, and building a dashboard.]]></summary></entry></feed>