Rewritten/updated on 18-04-2024

Using the CoreTrust bug (CVE-2022-26766) from Linus Henze, originally utilised by TrollStore 1.x, it is possible to get unsandboxed and untethered code execution on iOS 14. In this post, I will describe how I managed to achieve this on my iPad running iOS 14.8.

If you’re not too familiar with how iOS works, I recommend reading the glossary at the end of this post to get a better understanding of what I’m talking about and the key terms mentioned before you begin.

Background

CoreTrust is a kernel extension that is called out to by AMFI (Apple Mobile File Integrity) to ensure that a binary has a CMS blob with a signature from Apple.

AMFI checks that the binary has a CMS blob, but does not actually validate it. Instead, CoreTrust evaluates it - with AMFI calling the CTEvaluateAMFICodeSignatureCMS function and passing the CMS data to it. CoreTrust makes sure that the CMS blob is valid and that the given hash(es) is correct. CoreTrust returns to AMFI a ‘policy’ which defines certain flags upon successful validation (these might be whether it is an App Store certificate or a developer certificate). More information about this can be found here.

There are two outcomes of CoreTrust evaluation:

  • The CMS is invalid - AMFI verification fails
  • The CMS is valid - CoreTrust returns flags to AMFI

If the policy flags indicate that the binary is App Store signed, it triggers the “App Store Fast Path”. This means that the binary receives no further checks and is allowed to run with any entitlements it has (note: there are a few entitlements that are restricted to trustcache binaries in iOS 15 and above, which you can read about here).

In versions iOS 14.0 - 15.4.1, there was an inherent vulnerability in CoreTrust - it never checked if the certificate was actually issued by Apple. This means that if you can get a certificate with the App Store extension set, you can pass all code signing checks successfully, even if the certificate is not actually signed by Apple.

This was obviously fixed by checking the root certificate issuer is actually Apple. However, the vulnerability exists on all iOS 14 versions, so it is still possible to exploit it on any of them.

Update: unbeknownst to me at the time of writing this, there was going to be another CoreTrust bypass that affected iOS 14.0 - 16.6.1 (as well as iOS 17.0). What is ironic is that I was the one who would patch-diff, exploit and release this bypass to the community!

A while back, the developer (asdfugil) used this vulnerability in order to create a proof-of-concept demonstration that allows for unsandboxed and untethered code execution. You can check it out here. The reason this works is because there is a binary executed by the system at the path /usr/bin/fileproviderctl that immediately executes /usr/local/bin/fileproviderctl_internal - or so I thought.

In this post, I will describe how I managed to get this working on iOS 14.8, and how I managed to get untethered code execution on my iPad despite the additional check that was added in later iOS 14.x versions, which prevented the original method from working.

Setting up the project

I’ve got this working before on iOS 14.0.1 a few months ago, so I went ahead this week to try on my iPad. I built the binaries, followed the steps described in the README, and placed them on my device just as I had before. I executed fileproviderctl_internal manually and there was output in my log file, which showed that it worked!

So I refreshed the log file and rebooted my device, and low and behold… nothing? For some reason, despite everything being in the right place on the filesystem, the code was not working.

I wondered if there was an issue with how the binary was signed, but it was fine. Then, I double checked the entitlements, which were also fine. I tried two more root certificates for signing the binary but I still couldn’t get it to be executed on boot.

I executed the binary for the daemon started on boot which should kickstart the untether and found that it executed fine - except that it still did not execute the binary at /usr/local/bin/fileproviderctl_internal even though I was jailbroken and code signatures wouldn’t be an issue!

Diagnosing the issue

I checked the syslog in the case that AMFI was throwing errors about my binary, and there was nothing. I came to the conclusion that the binary was signed okay.

So, I put the fileproviderctl binary into Binary Ninja and found this in the entry function:

if (_sandbox_check(_getpid(), 0, 0) != 0)
		_errx("Trying to invoke fileproviderctl from sandbox")
		exit(1)
if (_os_variant_has_internal_content("com.apple.FileProvider") != 0)
    _execv("/usr/local/bin/fileproviderctl_internal", cliArguments)

So, if the _os_variant_has_internal_content() function returns true, the /usr/bin/fileproviderctl binary should launch the fileproviderctl_internal binary and pass the original arguments passed to it in the first place. I found this function open-sourced by Apple and looked further into it.

Here is the function we are looking for:

bool
os_variant_has_internal_content(const char * __unused subsystem)
{
  if (_check_disabled(VP_CONTENT)) {
  	return false;
  }

#if TARGET_OS_IPHONE // this would be us
	return _check_internal_release_type();
#else
	return _check_internal_content();
#endif
}

From what I gathered, the function basically returns whether the device is running an internal operating system build or a production/public build. To compare, I got the same binary from the iOS 14.0 filesystem and, sure enough, found a difference:

if (_sandbox_check(_getpid(), 0, 0) != 0)
  _errx("Trying to invoke fileproviderctl from sandbox")
  exit(1)
_execv("/usr/local/bin/fileproviderctl_internal", originalArguments)

On iOS 14.0, the binary performs no checks for an internal build and executes fileproviderctl_internal regardless. However, on my device, it doesn’t even attempt to execute the binary because I’m not running an internal iPadOS build!

Someone recommended to me that I should try and edit /System/Library/CoreServices/SystemVersion.plist - seeing as that is allegedly how iOS checks if it is an internal build. Unfortunately, this ended up causing a bootloop on my device and I had to restore the original file using an SSH ramdisk (thank you checkm8!).

Alternative exploitation method

The additional check in later iOS 14.x versions cancelled out the easiest (and safest) way to get untethered code execution. For our untether to be successful, we need our code to be executed automatically on boot.

The original method replaced the analyticsd daemon, which doesn’t largely affect the system if not executed. This would have only worked because the binary that replaced analyticsd (fileproviderctl) was already in trustcache so launchd was happy to execute it.

The problem is, we now don’t have another file to replace a daemon with, because none of them execute another file like fileproviderctl does (apart from /usr/bin/brctl - but that also got the same internal build check). Therefore, we would now need to use our own binary to be spawned by launchd - which is another problem in itself. Without further modifications, launchd will not execute our binary unless it is in trustcache.

So, if launchd will refuse to execute our binary, and there is no way to get around this check without prior code execution, we only have one option: replace launchd itself.

This is highly risky for two main reasons:

  • launchd is one of the first processes that runs upon boot - if it fails, boot fails
  • We cannot fix it if something goes wrong unless we use a BootROM exploit

Therefore, this is only safe to do on checkm8-vulnerable devices where we can easily reverse our changes using an SSH ramdisk.

launchd2

Thankfully, the original haxx repo has an implementation of this - which clearly states it was only tested once and is not safe. Regardless, I decided to go ahead and give it a go. I build the project and verbose booted with checkra1n - this is what I saw:

WE ARE PID 1

If you can’t read that, it says “We are PID 1” - aka process ID 1, which is the PID that launchd has. Sure enough, that’s exactly what should be printed if our fake launchd gets executed. So that was it, untethered code execution on boot! However, this was booted with checkra1n (all code-signing is patched out), so I couldn’t be absolutely sure that this would work on a regular boot.

I rebooted the device to jailed mode and it booted successfully - which is all I needed for confirmation. Because we replaced the launchd binary, the only way our system could have booted is if our fake launchd spawns the actual one - which it did.

So, for now, I’ve achieved untethered code execution. This is only the first step in an actual untethered jailbreak. For that, there are a few extra steps. In short: execute fake launchd, spawn a kernel exploit, spawn anymore exploit processes needed, deploy the bootstrap if needed and then kickstart the jailbreak. That is - I believe - completely out of my depth, there isn’t even a public jailbreak for iOS 14.8 on A11 and below apart from checkra1n that I could use to help me.

But there is always the PoC from Saar Amar for the IOMobileFrameBuffer exploit - maybe one day I could give it a try…

Update: there is now a public kernel exploit for all iOS 14 versions, kfd.

Conclusion

This was a very interesting project because it wasn’t something I’d tried before. While I may not have the skills to make a full untethered jailbreak, I’m glad I was able to get this far. It was also good to get some reverse engineering experience, as I’m not too skilled in that either. Lesson learned: don’t assume that Apple haven’t made minor, but important, changes during minor updates.

I hope this writeup was interesting to read and that it explained the process well. If you have any questions, feel free to ask by emailing me at [email protected].

Thanks to the following people for helping me with this project:

Glossary

  • daemon - a process that is started on boot and runs in the background.
  • AMFI - Apple Mobile File Integrity - a kernel extension that checks the code signature (AKA the original source) of binaries before they are executed. It has an accompanying daemon called amfid.
  • CoreTrust - a kernel extension introduced in iOS 12 that helps AMFI to verify the actual signature blob of a binary.
  • launchd - launch-daemon - the first process upon boot that spawns all other processes.
  • trustcache - a list of ‘trusted’ CD hashes of binaries that the kernel will not validate the cryptographic signature of - essentially, a list of binaries that are allowed to run without needing a valid CMS blob.
  • entitlements - properties of a binary that grant it permission to do certain things that it wouldn’t normally be able to do (e.g. spawn other processes, run without a sandbox).