Getting sandbox extensions you were never entitled to (CVE-2026-86886)

an App Store app, with no entitlements at all beyond the default sandbox, can ask iOS nicely for read-write access to a handful of system group containers and actually get it. the MobileGestalt cache, configuration profiles, icon caches… containers an app should never even see the path of.

containermanagerd

every process on iOS runs in a sandbox, and a big part of that sandbox is which container directories it's allowed into : its own data container, the group containers it's entitled to, and nothing else. the thing that actually makes that decision and hands out the access is a daemon called containermanagerd

when a process wants a container, it asks containermanagerd over XPC, and the daemon answers with a sandbox extension token

none of that is public API. apps go through the high-level stuff (NSFileManager, containerURLForSecurityApplicationGroupIdentifier…) that eventually talks to the daemon for you. but the machinery underneath lives in libsystem_containermanager.dylib, and it's full of container_query_* functions that are just… there, dlopen-able from any app

so i spent a while reversing the daemon and that dylib statically, mapping out what the query functions do and, more importantly, what the daemon checks before it grants anything. and then i just tried things

The SPI

the flow is quite small ; what you need is to build a query object, set a few fields on it, ask for the result, and activate the extension :

let containerLib = dlopen("/usr/lib/system/libsystem_containermanager.dylib", RTLD_NOW)

let create   = sym("container_query_create")
let set_cls  = sym("container_query_set_class")
let set_tran = sym("container_query_set_transient")
let set_gids = sym("container_query_set_group_identifiers")
let set_flag = sym("container_query_operation_set_flags")
let set_part = sym("container_query_operation_set_part")
let get_res  = sym("container_query_get_single_result")
let activate = sym("container_object_sandbox_extension_activate")
let get_path = sym("container_object_get_path")

(sym is just a dlsym + unsafeBitCast to the right C function type. every one of these resolves fine from a default sandbox.)

the field that matters is the class. containers are grouped into classes : your app's own data container, app groups, system groups, and so on. class 13 is system group containers, the systemgroup.com.apple.* ones that system daemons share between themselves. an app is normally entitled to exactly none of those

so i point the query at class 13, name a system group, and ask :

let query = create()
set_cls(query, 13)                   // system group containers
set_tran(query, false)

let groups = xpc_array_create(nil, 0)
xpc_array_set_string(groups, XPC_ARRAY_APPEND, "systemgroup.com.apple.mobilegestaltcache")
set_gids(query, groups)

set_flag(query, (1 << 32) | (1 << 39))   // bit 32: resolve the path, bit 39: mint a read-write extension (see below)
set_part(query, 3)                   // Library/Caches

let result = get_res(query)          // daemon resolves the path + mints a token
activate(result, true)               // kernel now lets us in
let path = String(cString: get_path(result))

and that's basically it ! get_path hands back the real on-disk path of the mobilegestaltcache system container, the extension is live, and plain POSIX open() on a file inside it works. as some might put it, it just works™ :

let file = path + "/com.apple.MobileGestalt.plist"
let fileDescriptor = open(file, O_RDWR)

the user never sees a thing, and the app never needed an entitlement for any of it

so what went wrong

containermanagerd is supposed to check that the caller is entitled to the group it's asking for. for most groups it does : of the ~20 system groups i threw at it, 14 came back with client is not entitled : keychain, trustd, accounts, wifi, bluetooth, telephony, the ones you'd hope are locked down

but a handful didn't check at all. for those, the daemon resolved the path and minted a com.apple.app-sandbox.read-write token for my unentitled app like it was the most normal request in the world. the ones i found :

  • systemgroup.com.apple.configurationprofiles
  • systemgroup.com.apple.mobilegestaltcache
  • systemgroup.com.apple.osanalytics
  • systemgroup.com.apple.installcoordinationd
  • systemgroup.com.apple.lsd.iconscache
  • systemgroup.com.apple.icloud.findmydevice.managed

(there are probably more tbh)

the part that makes it actually bad is read-write, not read-only. once you're in you can list, read, create, overwrite and delete :

  • mobilegestaltcache : read and write com.apple.MobileGestalt.plist. that's the cache iOS uses for device properties : model, capabilities, feature flags. you can do all the fun stuff MANY mobilegestalt tweaks do
  • configurationprofiles : whether the device is MDM-managed, the profile state, that kind of thing
  • lsd.iconscache / osanalytics / installcoordinationd : icon caches, analytics data, install coordination state. all corruptible, deletable (icon theming ?)
  • findmydevice.managed : FMIPStateInfo.plist, which holds the Find My activation state

a flashlight app could do any of the read/write ones in the background and you'd never know

the flags

i found (1 << 32) | (1 << 39) by reversing MCMXPCMessageQuery in ContainerManagerCommon.framework : the Flags uint64 from the XPC message gets unpacked bit by bit into ivars. the two that matter :

  • bit 32 → _includePathInResult : tells the daemon to resolve the container's on-disk path and include it in the reply. without this, you get no path at all
  • bit 39 → _sandboxExtensionType = 3 : tells the daemon to mint a sandbox extension and attach it to the reply. the type is an enum (MCMSandboxExtensionType) :
value name extension class
0 none —
1 container container-scoped
2 readOnly com.apple.app-sandbox.read
3 readWrite com.apple.app-sandbox.read-write

bits 35 and 38 set the type to 1 and 2 respectively, but bit 39 skips them and sets it straight to 3 (read-write). so (1 << 32) | (1 << 39) = “resolve the path and give me a read-write sandbox extension”. that's what the daemon then does ; the entitlement check that should sit in front of that just isn't on the code path for the affected groups

the other two operation fields turned out to be mostly noise :

  • container_query_operation_set_platform : no effect i could find, 0 through 4 all behave identically
  • container_query_operation_set_part : picks the subdirectory. 2 is Library/Preferences, 3 is Library/Caches. 0–1 return entitlement errors (probably / and /Library of the group itself), and 4+ crash containermanagerd outright, which i'm guessing is just running off the end of an enum 😭

disclosure

reported to Apple mid-july 2026, fixed in iOS 27.0 / 26.7 as CVE-2026-86886. credited, no bounty (the usual)

All posts