App Attest: How to prevent an iOS app's APIs from being abused

App Attest: How to prevent an iOS app's APIs from being abused

When jailbreaking of iOS devices first became popular, it was very common for iOS developers to try to defend their apps from users that altered their devices to enable piracy. There were many ways of doing that, which included checking for the existence of Cydia, checking if the app could read files outside its sandbox, crashing the app if it detects a debugger and more.

As time has shown, these "defensive" measures were and still are a really bad idea. If an attacker has physical access to the device, there's no way you can trust your app's logic. It was and still is trivial for hackers to "pretend" their devices were not jailbroken and effectively bypass these measures, and besides, having a jailbroken device doesn't mean the user wants to pirate content -- some people just want to have cooler looking home screens.

As (possibly) a response to jailbreaking become popular again in recent times, Apple has released their own measure to this problem. In iOS 14, the new App Attest APIs provide you a way to sign server requests as an attempt to prove to your server that they came from an unmodified version of your app.

It's important to know that App Attest is not a "is this device jailbroken?" check, as that has been proven over and over to be impossible to pinpoint. Instead, it aims to protect server requests in order to make it harder for hackers to create compromised versions of your app that unlock premium features or inserts features like cheats, while also adding a layer of protection against replay attacks against your server's APIs. Note the word harder: as jailbreakers have physical access to their devices, nothing will completely safe-guard you from fraud in this case.

As you can't trust your app to protect itself, App Attest requires work on your backend to be fully implemented. I won't go through the backend part of it as this is a Swift blog, but will at least mention how it works to show how it wrap things together.

Generating a pair of keys to sign requests

App Attest works through the use of an asymmetric public/secret pair of encryption keys. The intention, in the end, is for your app to sign server requests with the secret key, send the data to the backend and have it confirm it to be true with the public one. If a hacker intercepts the request, it will not be able to alter its contents without making the backend's subsequent validation fail.

To generate the keys, import the DeviceCheck framework and call the generateKey method from the DCAppAttestService singleton:

import DeviceCheck

let service = DCAppAttestService.shared
service.generateKey { (keyIdentifier, error) in
    guard error == nil else {
        return
    }
}

The keys generated by App Attest are safely stored in your device's Security Enclave. As you can't directly access it, the result of this method will be a keyIdentifier property that allows iOS to find the keys when needed. You need to store it so you can later validate your app's requests.

It's important to mention that App Attest is not supported by all types of devices, and if you look at Apple's own documentation, they will ask you to first check if it's supported and have your server support a fallback in case it's not:

if service.isSupported { ... }

Do not do this! As said before, it's trivial for a jailbreak user to "pretend" their device doesn't support it. Apple doesn't expand on this topic, but the reasons for this check to exist appears to be that there are some Macbooks that don't have the necessary chip to support it. However, as investigated by Guilherme Rambo, it appears that every single iOS device supports it. For an iOS app, you do not need to do a compatibility check.

Attesting: Sending the public key to the backend

In order to sign server requests, you need to provide your backend with a way to confirm that signature. This is done by giving the backend access to the public key we previously generated, but we can't simply create a request and add it as a parameter because it would be pretty easy for a hacker to intercept it and send their own public key instead, giving them full control of what your app sends to the backend.

The solution to this problem is to ask Apple to attest that what the key we're sending originated from a uncompromised version of your app. This is done by calling the attestKey method, which receives the key's identifier as a parameter:

service.attestKey(keyIdentifier, clientDataHash: hash) { attestation, error in
    guard error == nil else { return }
    let attestationString = attestation?.base64EncodedString()
    // Send the attestation to the server. It now has access to the public key!
    // If it fails, throw the identifier away and start over.
}

This method accesses a remote Apple server, and the result is an "attestation" object that contains not only your public key, but a ton of information about your app that serves as a statement from Apple that the previously generated keys are not fake. When you receive this object, you must send it to your backend and have it perform several validations on it that allows it to confirm that it was unaltered. If the attestation object was successfully validated, the backend will be able to safely extract the app's public key from it.

It's unclear if Apple attempts or not to check if the user's device if jailbroken during this process. It's never mentioned that this is the case, but they do say "App Attest can’t definitively pinpoint a device with a compromised operating system." which could imply that they at least try something. It's probably safe to assume that this is not the case, and the word attest here simply means that your request (probably) wasn't intercepted and modified.

The additional clientDataHash parameter of the attestation request is not related to the attestation process itself, but extremely important for it to make it safe. As it is, this request is susceptible to a replay attack where a hacker could intercept the validation request and steal the attestation object sent from Apple so that later they can "replay" the same validation request at a fake version of your app to make the server believe it came from the real one.

A solution to this problem is to simply not allow the validation request to be executed freely. Instead, the client can provide a one-time use token (or session ID) that the server will expect to accompany the request to ensure its validity. If the same token is used twice, the request will fail. That's what clientDataHash is for: By providing a hashed version of that expected token to the attestation request, Apple will embed it into the final object and provide your server a way to extract it. With this, it's pretty hard for a hacker to create a compromised version of your app by simply intercepting requests.

let challenge = getSessionId().data(using: .utf8)!
let hash = Data(SHA256.hash(data: challenge))
service.attestKey(keyIdentifier, clientDataHash: hash) { ... }

As mentioned earlier, Apple suggests you to not reuse keys. You should do this entire process for each user account in a device.

Because this request relies on a remote Apple server, it's possible for it to fail. If the error is that the server was unavailable, Apple says that you can simply try again, but if it's anything else, you should discard the key identifier and start over. This can happen for example when a user reinstalls your app -- The keys that you generate remain valid through regular app updates, but don’t survive app reinstallation, device migration, or restoration of a device from a backup. For these cases, your app needs to be able to restart the key generation process.

From the server side of things, it's also interesting to mention that the statement object also contains a receipt that your server can use to request fraud assessment metrics from Apple. This allows you to check the number of generated keys and the devices that they have been associated to detect possible cases of fraud. Apple specifically mentions the possibility of an attack where a user could use one device to provide valid assertions to compromised devices, which can be detected by this fraud assessment by locating users with unusually high amounts of assertion requests.

Wrapping it up: Encrypting Server Requests

After attesting the validity of the key, your backend will have access to the public key. From now on, every time you're dealing with sensitive content, you have the ability to safely sign that request. The generateAssertion method used for this works very similarly to the attestation of the keys, except this time you're attesting the request itself:

let challenge = getSessionId().data(using: .utf8)!
let requestJSON = "{ 'requestedPremiumLevel': 300, 'sessionId': '\(challenge)' }".data(using: .utf8)!
let hash = Data(SHA256.hash(data: requestJSON))
service.generateAssertion(keyIdentifier, clientDataHash: hash) { assertion, error in
    guard error == nil else { return }
    let assertionString = assertion?.base64EncodedString()
    // Send the signed assertion to your server.
    // The server will validate it, grab your request and process it.
}

Just like before, your backend must support the usage of a one-time token to prevent replay attacks. This time, since the request itself is our clientDataHash, we're adding the token inside the JSON. There's no restriction on the number of assertions that you can make with a given key, but still, you typically should reserve that for requests made at sensitive moments of your app such as the download of premium content.

In this case, your additional protection comes from the fact that the request is hashed and usable only once. Because the entire request is signed by your private key, a hacker can't simply intercept your requests and use them to craft their own. They must figure out where the parameters of your request are coming from and manually attempt to sign it, something that will take slightly more skill than simply attaching a proxy. As mentioned in the beginning, it's not impossible to break this protection, just harder.

Testing and rolling out your implementation

The App Attest service records metrics that you can't reset. To prevent that, apps not in a production environment will use a sandboxed version of it. If you instead want to test in the production environment, you should add the com.apple.developer.devicecheck.appattest-environment entitlement to your app and set its value to production.

If you have a large user base, Apple recommends you to gradually roll this feature as requests to attestKey are rate limited. After carefully rolling it out for existing users, you can guarantee that it will only be called for new users.

Conclusion

By implementing this in your client and in your backend, it should become harder for hackers to abuse your server's APIs. However, be aware of the word harder -- it doesn't mean impossible! As mentioned before, there's no sure way for you to detect if a user has a jailbroken device, and even fewer ways to prevent them from attacking your app. As with most security measures, the intention of App Attest is instead to make this process difficult enough so that only a very skilled and dedicated hacker would be able to find a way to break into your app -- someone much harder to come by.

References and Good Reads

Official App Attest Docs covering the Backend implementation