A jailbreak tweak that spoofs iOS location system-wide by hooking locationd, the
system location daemon — not the individual apps.
That is the whole point. The usual location fakers hook CoreLocation inside each app,
which fails against an app you cannot inject into. Under roothide you can blacklist
an app so no tweak dylib is loaded into it (to hide the jailbreak). But every app still
gets its position from locationd over XPC — so hooking the daemon reaches the
blacklisted app anyway. locationd is the one interception point that works against a
process you cannot touch, and it is the reason this project exists.
A second, optional path covers apps that do permit injection: an in-process CoreLocation fallback that is on by default for every app (exclude specific apps from a Settings picker). It adds what the daemon cannot — a synthetic update timer that keeps an app moving even when the real subsystem is silent — but the daemon stays the path that reaches the blacklisted target.
GeoShim is static only: it holds one configured coordinate (with realistic per-fix uncertainty), not routes.
It also cleanly defeats sourceInformation.isSimulatedBySoftware: tools built on
CLSimulationManager (Xcode's DDI, locsim) deliver through the channel iOS marks as
simulated, so on iOS 15+ every CLLocation they produce reports isSimulatedBySoftware == YES to any app that checks. GeoShim constructs the CLLocation objects itself, so it
controls that property.
Target: iOS 15.8.2, Dopamine2‑roothide (ElleKit / systemhook), arm64e.
Package xyz.regulad.geoshim. Paths are resolved at runtime with jbroot(); nothing
hardcodes the randomized jailbreak root.
Status: confirmed working on-device. The daemon-only build spoofs a
roothide‑blacklisted app's location on iOS 15.8.2 / Dopamine2‑roothide, verified in the
log by locationd: encode <real> -> <fake>.
GeoShim has two paths and splits at load time by process. The locationd daemon path is
primary — and the only one that reaches a roothide-blacklisted app. Inside the daemon it
hooks the two points where an outbound, location-bearing object is serialized to a client.
Positions leave locationd as an NSSecureCoding keyed archive over XPC (bplist17,
confirmed by 8kSec's Frida teardown
and on-device here), so the serializer is the chokepoint:
-[CLLocation encodeWithCoder:]— the main path. Rather than guess the archive's key names, GeoShim replaces the object being encoded: it builds a fully spoofedCLLocationand runs the original encoder against that. Whatever keysCLLocationnormally writes, it writes for the fake. Objects with an invalid coordinate are left alone; anything that throws falls through to the real encode.-[CLVisit encodeWithCoder:]—CLVisitcarries its own coordinate and rides the same path, so a visit-monitoring app would otherwise see the real place. GeoShim rewrites the coordinate on a copy (never mutating the daemon's own object), reaching it through theCLVisitcoordinate ivar since the class has no public initializer. The ivar offset is discovered at load; if the layout is unexpected the hook is simply not installed (fail closed — the real visit is encoded).
Everything fails open: any nil/throw path encodes the real object untouched. A crash
here is a crash in locationd, so every hook is nil-checked and wrapped.
The CLI also pings this path: geoshim status posts a Darwin notification the daemon
answers with its live state, so the tool can tell you the hook is actually loaded in
locationd rather than just echoing the plist it wrote. No reply means the tweak isn't live.
In an app that does permit injection, GeoShim also hooks CoreLocation in-process. This is
redundancy plus the one thing the daemon cannot do: a synthetic update timer that keeps
the app moving even when the real subsystem is silent (permission denied, GPS cold, or a
poll-only client). It is normally on for every app: it installs unless the app is
switched off in Settings → GeoShim → Apps (an AltList picker, the same model VPNHide
and Choicy use) or the master switch (geoshim fallback, on by default) is off.
-[CLLocationManager setDelegate:]— the delegate lives in the host app, so its callbacks can't be hooked by name. On first sight of a delegate class GeoShim swizzleslocationManager:didUpdateLocations:and the legacylocationManager:didUpdateToLocation:fromLocation:once, substituting the location argument and calling on to the original.-[CLLocationManager location]— for apps that poll rather than subscribe.startUpdatingLocation/stopUpdatingLocation/requestLocation— drive the synthetic timer. It skips a tick if a real callback landed within the last 1.5 intervals (so the app is never fed two fixes for one moment) and delivers through the delegate's original IMP, so its own delivery is never re-substituted or mistaken for a real fix.
Reading the exclusion list and coordinate from inside a sandboxed app is the interesting
part: cfprefsd denies a foreign app the cross-domain read, so GeoShim applies a
libSandy profile
(layout/Library/libSandy/GeoShim.plist) in its constructor, granting a sandbox file-read
extension on the prefs plist. The roothide libSandy fork runs jbroot() on the profile path
before issuing the extension, and GeoShim reads that same jbroot()'d path (GSCommon.h),
so the grant and the read line up. The daemon path needs none of this — locationd reads
the plist directly.
GSLocationFactory populates every field per delivery rather than leaving placeholders:
- coordinate jittered by a Gaussian ±
JitterMetres— the configurable uncertainty (metres → degrees, latitude-corrected,cos(lat)clamped near the poles) horizontalAccuracya random walk over 5–65 m,verticalAccuracyover 3–10 m — a walk, not a constant and not uniform noise, because that is how a receiver behaves as its solution firms up or degradesaltitudea bounded random walk around the configured value (±8 m barometric drift) plus a little per-fix noise — quiet but never frozenspeeda small non-zero value (a stationary receiver never reads exactly 0);speedAccuracya small positive value, never −1course−1 andcourseAccuracy−1 (a static fix has no meaningful heading)timestampis[NSDate date]at delivery
SourceInfoMode selects how the property reads, so you can A/B it against a target:
nil(default) — the public initializer, so the property isnil. This is what any ordinaryCLLocationlooks like, and it needs no private API.clean— attaches[[CLLocationSourceInformation alloc] initWithSoftwareSimulationState:NO andExternalAccessoryState:NO]. That initializer is public, declared inCLLocation.hsince iOS 15.0.passthrough— copies whatever the last genuine fix reported.
Attaching the object is the one part with no public answer. Two strategies, resolved once
and logged in debug builds: (1) a private initializer taking the source info, looked up by
name and validated against its own NSMethodSignature before being invoked, else
(2) a CLLocation subclass overriding -sourceInformation. See Known limits
for the class-identity caveat of strategy 2.
| Path | What it is |
|---|---|
Tweak.x |
Both paths: the locationd CLLocation/CLVisit swizzles + status responder, and the app-side CLLocationManager fallback hooks |
GSLocationFactory.{h,m} |
Builds the spoofed CLLocation; noise, accuracy, sourceInformation |
GSConfig.{h,m} |
Preferences snapshot (incl. AppFallback / DisabledBundles); Darwin-notification live reload |
GSCommon.h |
Identifier, jbroot()-based path, status-ping notify names + coordinate fingerprint, logging gate |
cli/main.m |
The geoshim control tool (writes prefs, pings the daemon) |
cli/entitlements.plist |
roothide no-sandbox entitlements for the CLI (see Building) |
prefs/ |
Settings bundle — status readout, MapKit picker, and the AltList exclusion picker |
GeoShim.plist |
Injection filter — locationd and CLLocationManager-using apps |
layout/Library/libSandy/GeoShim.plist |
libSandy profile granting the prefs file-read extension |
layout/Library/PreferenceLoader/Preferences/GeoShim.plist |
Settings entry |
Theos on Linux (this tree builds from WSL2 Fedora against the iPhoneOS16.5.sdk and the
Linux toolchain). Requirements beyond a stock Theos:
- roothide Theos support — the
roothidepackage scheme andjbroot()(<roothide.h>) come from roothide's Theos fork. A stock Theos fails at'roothide' package scheme does not exist. Install per https://roothidebootstrap.com/develop/ or switch Theos to the roothide branch. dpkgandldid, which Fedora does not ship:
sudo dnf install -y dpkg
git clone --depth 1 https://github.com/ProcursusTeam/ldid.git && cd ldid
make && sudo install -m 0755 ldid /usr/local/bin/ldid- AltList and libSandy staged into Theos — the Settings picker links AltList's
ATLApplicationListMultiSelectionController, and the tweak links-lsandy(roothide's libSandy fork). roothide's Theos bundles both; if a link step fails, run AltList'sinstall_to_theos.shonce. These are also runtimeDependsincontrol(com.opa334.altlist,com.opa334.libsandy,preferenceloader).
Then:
export THEOS=$HOME/theos
export PATH=/usr/local/bin:$PATH
make package # development build: 1 Hz logging in locationd
make package FINALPACKAGE=1 # release: logging stripped — ship thisDebug logging is compiled in for a normal build and stripped only when
FINALPACKAGE=1, matching the Theos convention.
THEOS_PACKAGE_SCHEME = roothide (in the Makefile) produces an iphoneos-arm64e deb with
files staged at jbroot-relative paths (no /var/jb), linked against libroothide.dylib
via the .jbroot symlink. The .deb lands in packages/.
cli/entitlements.plist (the four keys from roothide's entitlements.md —
platform-application, com.apple.private.security.no-sandbox, …storage.AppBundles,
…storage.AppDataContainers) is signed into geoshim via geoshim_CODESIGN_FLAGS.
Without no-sandbox the tool runs containerized: its preference writes land in a
private container locationd never sees, and dyld is denied access to jbroot libraries
(so it fails to load even when the file is present). This is not optional.
Build from Windows via
wsl.exe: pass the script on stdin (wsl.exe -d <distro> -- bash < script.sh). Asbash -lc '...'the$VARreferences get mangled in transit.
Install through Sileo/Zebra (or apt), not a bare dpkg -i. roothide's package
manager runs the dpkg pipeline that creates the per-directory .jbroot symlinks the
binaries need to resolve @loader_path/.jbroot/... dependencies. A wrong-context
dpkg -i can leave .jbroot pointing at // (the rootfs root), which breaks dylib
resolution — the symptom is dyld: Library not loaded: @loader_path/.jbroot/usr/lib/….
# copy the .deb over, then on-device:
sudo apt install ./xyz.regulad.geoshim_0.2.0_iphoneos-arm64e.deb
killall -9 locationd
geoshim statusDopamine2‑roothide injects locationd natively: systemhook is inserted into launchd and
hooks posix_spawn/execve, so every process — daemons included — inherits injection.
locationd is not on Dopamine's tweak-injection exclusion list (only xpcproxy
always, and logd/notifyd/usermanagerd on iOS 16+), so no resignList step is
required — that mechanism is specific to the TrollStore roothide Bootstrap, a different
product. Just make sure Tweak Injection is enabled and you are not in safe mode
(ls "$(jbroot /basebin/.safe_mode)").
geoshim start -x 35.6812 -y 139.7671 [-a 40.0] [options]
geoshim stop
geoshim fallback <on|off>
geoshim status
Options for start:
| Flag | Effect |
|---|---|
--source-info <nil|clean|passthrough> |
how CLLocation.sourceInformation reads |
--jitter <metres> |
per-update coordinate uncertainty (default 2 m) |
--debug-log-real |
log genuine fixes — needs a development (non-FINALPACKAGE) build |
--interval <s>, --no-synthesize |
synthetic-timer cadence / disable it — used by the app-side fallback |
geoshim fallback on|off is the master switch for the app-side hooks (on by default); the
locationd path is always active. The fallback is normally on for every app; exclude
specific apps in Settings → GeoShim → Apps. geoshim status pings locationd (see below),
asserts it is serving the configured coordinate, and prints the configured prefs including
the master switch and the excluded apps.
geoshim status --json prints the whole snapshot as one JSON object. The Settings page uses
this, which is why the GUI never reimplements the ping / fingerprint / hook logic — the CLI
is the single source of truth:
{"daemon_alive":true,"enabled":true,"cl_hook":true,"visit_hook":true,"serving":true,"has_coordinate":true,"latitude":41.29840,"longitude":-81.75881}| Field | Meaning |
|---|---|
daemon_alive |
the tweak answered the ping — it is loaded in locationd |
enabled |
spoofing is on (the daemon's live state if it answered, else the plist) |
cl_hook / visit_hook |
the CLLocation / CLVisit encodeWithCoder: hooks are installed |
serving |
the daemon is enabled and its live coordinate matches the plist (the assertion) |
has_coordinate |
a coordinate has been set |
latitude / longitude |
the configured coordinate (decimal degrees) |
Any field is false/0 when unknown (e.g. all-false when locationd doesn't answer).
Everything the CLI does is also on the Settings → GeoShim page (a PreferenceLoader
entry backed by the GeoShimPrefs bundle), so you never need a terminal for day-to-day
use:
- Status — three live rows read from
geoshim status --json, refreshed on appear, on the reload notification, and on a 2 s poll: Hook State (is the tweak loaded and responding inlocationd), Spoofing State (on/off, oron (daemon coordinate differs)), and Coordinate. Because they come from the daemon rather than the plist, they never lie. - Spoofing → Enabled — the one toggle. It runs the real
geoshim start/geoshim stop(Preferences gets a libSandy exec grant on the binary), so it drives the exact same path as the CLI and its displayed value comes from the daemon. There is deliberately no GUI toggle for the app-side hook — usegeoshim fallback on|off. - Set location on map… — a full-screen MapKit picker. Pan the map under the crosshair (the live coordinate shows at the bottom) and tap Save; it writes the coordinate and posts the reload, and the daemon bounces to serve it. It opens centred on the current coordinate.
- App-side fallback → Apps — the AltList exclusion picker. The fallback is on for every
app by default, so an app switched off here is written to the
DisabledBundlesarray thegeoshim fallbackcommand and the tweak's self-gate share.
The status rows and the Enabled switch go through the geoshim CLI (spawned with the libSandy
exec grant), so the GUI never reimplements state logic or reads a stale preference cache. The
Apps picker still writes the xyz.regulad.geoshim domain through cfprefsd.
The tool writes the plist (readable by mobile) and posts
xyz.regulad.geoshim/prefsChanged. locationd re-reads it live via a Darwin-notification
observer — no respring. On a real coordinate or enabled/disabled change, the tweak inside
locationd then SIGKILLs the daemon itself; launchd relaunches it and every client
re-registers and re-grabs the new position — the reliable way to make a change take hold for
clients that cache a fix. This covers changes from the CLI and from the Settings app,
with no manual killall.
jbroot(/var/mobile/Library/Preferences/xyz.regulad.geoshim.plist)
| Key | Type | Default | Meaning |
|---|---|---|---|
Enabled |
bool | false | Master switch for spoofing (both paths) |
AppFallback |
bool | true | Master switch for the app-side hooks (geoshim fallback) |
DisabledBundles |
array | [] |
Bundle IDs excluded from the normally-on app-side fallback (set in Settings) |
Latitude / Longitude |
real | — | Static coordinate |
Altitude |
real | 0 | Metres. Set it — 0 m in Denver is a tell |
SourceInfoMode |
string | nil |
nil, clean, passthrough |
JitterMetres |
real | 2.0 | Gaussian coordinate uncertainty per update |
UpdateIntervalSec |
real | 1.0 | App-side synthetic-timer cadence |
SynthesizeUpdates |
bool | true | App-side synthetic timer on/off |
LogRealFixes |
bool | false | Debug builds only; logs genuine fixes |
UpdateIntervalSec / SynthesizeUpdates drive the app-side fallback's synthetic timer;
they do nothing on the daemon path, which only rewrites fixes locationd already emits.
Anything unparseable — a missing plist or a bad coordinate — resolves to disabled, not an
error. The tweak fails open by construction.
Use a development build (make package, logging on by default) and watch with oslog | grep GeoShim (locationd is the only
process that logs [GeoShim]) or idevicesyslog | grep GeoShim.
- It loads.
killall -9 locationd, then expectloaded in locationd (…),CLLocation encodeWithCoder: hook installed, andCLVisit encodeWithCoder: hook installed (coordOffset=N …). AcoordOffset≥ 0 means theCLVisitivar was found;CLVisit coordinate ivar not foundmeans visits are left unspoofed on this build (report it and it's an easy fix). - The blacklisted app moves.
geoshim start -x 51.5074 -y -0.1278, open the target app. Each outbound fix logslocationd: encode <real> -> <fake>— that line is the proof the daemon path is doing the work. Because the app gets no injection, its moving can only be the locationd hook. - Property sanity. Confirm accuracy/timestamp/speed vary per the fields above; a constant accuracy or stale timestamp means something is wrong.
sourceInformationbehaves as designed —--debug-log-real, readreal fix:with spoofing off; compare against VERIFY.- No regression when disabled.
geoshim stop,killall -9 locationd; behaviour must be identical to the tweak not being installed. The safety net — do not skip it.
geoshim status also pings locationd and prints serving configured coordinate: YES when
the daemon's live coordinate matches the plist — the assertion that the tweak is not just
loaded but actually serving the value you set.
locationd is a daemon every process relies on; a crash in this dylib is a crash in it.
Keep this ready before first install.
ssh root@<device>
# `jbroot /path` on device prints the real path.
mv "$(jbroot /Library/MobileSubstrate/DynamicLibraries/GeoShim.dylib)"{,.disabled}
killall -9 locationdWith the dylib gone the filter matches nothing; to remove entirely, apt remove xyz.regulad.geoshim. A crash-looping locationd leaves the device usable (location just
stops) and SSH working — which is why the daemon target is survivable. The filter targets
only locationd; do not add UI-critical processes.
Confirmed on-device (iOS 15.8.2 / Dopamine2‑roothide), previously the biggest unknowns:
- Daemon injection works on iOS 15.
loaded in locationdfires;locationdinherits systemhook injection and is not excluded. locationddelivers viaencodeWithCoder:.locationd: encode <real> -> <fake>logs on each fix and clients move. The bplist17/NSSecureCoding assumption held.locationdcan read the jbroot prefs. The daemon reads and spoofs aftergeoshim start, so its sandbox reaches the plist.
Still open / device-specific:
CLVisitivar layout. The coordinate ivar offset is discovered at load and logged (coordOffset=…). If it ever readsnot found, the class layout differs and the rewrite needs the ivar name adjusted; until then visits fail closed (real coordinate).sourceInformationof a genuine fix on 15.8.2. Run the--debug-log-realprobe; if real fixes report a non-nil object with both flagsNO, switch fromnilto--source-info clean. In the daemon path,clean/passthroughonly carry through the private initializer (strategy 1); the subclass fallback encodes nil sourceInformation — coordinate/altitude/speed/course/timestamp always carry.Depends: ellekit. Confirm withdpkg -l | grep -iE "ellekit|substrate"; editcontrolif your bootstrap names it differently.
dyld: Library not loaded: @loader_path/.jbroot/usr/lib/… — the .jbroot symlink for
the binary's directory is wrong (often -> //). Reinstall through Sileo/apt, which
recreates it. If the CLI specifically fails, it also needs the no-sandbox entitlement
(see Building) — without it the sandbox denies dyld the jbroot path even when the file
exists.
loaded in locationd never appears — injection isn't reaching the daemon. Confirm
Tweak Injection is on and you're not in safe mode; on Dopamine2‑roothide no resignList
step is needed.
config: no readable plist — locationd cannot read the plist. Confirm it exists and
is mobile-readable (geoshim sets 0644, chowns 501:501 when run as root). If geoshim
itself wrote it to the wrong place, it is missing the no-sandbox entitlement.
App shows the wrong place but locationd: encode … logs — locationd isn't delivering
that app's fixes via encodeWithCoder:; class-dump locationd for the actual delivery
selector (around CLDaemon / CLXPCConnection).
geoshim status says value differs — locationd is live but serving a different
coordinate than the plist. Normally the daemon SIGKILLs and relaunches itself on a change;
if this persists, force it with killall -9 locationd. (locationd's own os_log output —
including the self-kill line — is only visible as root.)
GeoShim owns the CLLocation / CLVisit delivery path out of locationd — which is what
the vast majority of apps use, and what sourceInformation checks read. It does not
touch any location signal an app gathers outside CoreLocation.
For this deployment (a Wi-Fi-only iPad, so anything cellular is not applicable):
- Connected Wi-Fi BSSID — primary.
CNCopyCurrentNetworkInfo/NEHotspotNetwork.fetchCurrentreturn the SSID/BSSID of the currently connected AP (not a scan — just the joined one), given the "Access WiFi Information" entitlement plus location permission. That BSSID maps to a real location through any Wi-Fi positioning DB and contradicts the spoof. locationd does not expose nearby-BSSID scans to apps, so the scan list is closed — but the connected AP is readable and out of our reach. - CoreBluetooth — primary.
CBCentralManagerscanning runs outside locationd. iOS randomizes peripheral MACs, weakening generic fingerprinting, but fixed-UUID iBeacons in a known venue are a strong location signal. (iBeacon ranging viaCLLocationManagergoes through locationd and is covered; rawCBCentralManagerscanning is not.) - IP geolocation (server-side). A backend geolocates the public IP. Coarse
(city/region), and it reveals a mismatch rather than a pinpoint — closed by egressing
at the spoofed location (a VPN upstream of the device, e.g. on a router, so the iPad
shows no
utuninterface for an app to detect). - Cellular — not applicable on this Wi-Fi-only iPad.
- Sensor disagreement.
CMAltimeterandNSTimeZone.localTimeZonestill describe the real environment, contradicting a spoofed coordinate far from the device.
- Region monitoring /
requestStateForRegion:. locationd computes region state from the real position and returns an enum, not a coordinate — so the encode-time hook can't touch it, and an app could binary-search its true position with a grid of regions. It requires "Always" authorization, so setting the app to "While Using" in Settings closes it (and visits, and significant-location-change) with no code. Covering it in-tweak means a deeper hook of locationd's region evaluation (private, class-dump needed). CLBeaconranging — only leaks if the app ranges iBeacons; no coordinate to rewrite.
- Not defeated: injected-image detection in locationd (
_dyld_get_image_namelistsGeoShim.dylib). Not reachable by a third-party app, but stated for completeness. - Class identity in
clean/passthroughwith the subclass fallback: a delivered location's class isGSLocation, notCLLocation;isKindOfClass:passes, an exact comparison does not. The defaultnilmode returns a genuineCLLocation. - System-wide by design on the daemon path.
locationdspoofs every client at once; there is no per-app control there — and the roothide-blacklisted target is reachable only by the daemon. The app-side fallback does add per-app control (the AltList picker), but only for apps that permit injection, so it can never scope the target. encodeWithCoder:is broad within locationd. Every valid-coordinate outbound fix is rewritten, including ones locationd archives for itself — so Settings → Privacy → Location → System Services → Significant Locations accumulates spoofed entries while on.
Bottom line for this iPad: the realistic ways an app still learns the true location are the connected Wi-Fi BSSID and CoreBluetooth (iBeacons) — both above CoreLocation, closed by moving the VPN upstream and (for BSSID) by the app not holding the Wi-Fi entitlement. The failure mode for this kind of tool is a user who believes it is stronger than it is.
locsim (https://github.com/udevsharold/locsim, GPLv3) is worth reading for its
coordinate plumbing, even though its delivery mechanism — CLSimulationManager,
gated by com.apple.locationd.simulation — is precisely the one this project avoids.
AGPLv3. The bundled AltList and libSandy are separate works under their own licenses.