Introduction
The purpose of this blog post is to demonstrate how to extract a library from an Apple visionOS 2.0 dyld shared cache to instrument it with QBDI on an Apple M1.
Since both, the Apple Vision Pro and the Apple M1 share the same architecture (arm64), we should (theoretically) be able to execute and instrument binaries from one platform to the other.
Given that the environment of iOS/visionOS is more restricted compared to a macOS running as root with SIP disabled, lifting and instrumenting iOS or visionOS binaries on an Apple M1 can open new opportunities for fuzzing, reverse engineering or vulnerability research.
In general, running or instrumenting an arbitrary function compiled for iOS/visionOS on an Apple M1 is not possible especially if the given function is using hardware-specific inputs.
That being said, research has already shown that we can mock, emulate1 or trick platform- or hardware-specific functions.
I have no doubt that people have already found workarounds for that :)
Dyld Shared Cache
To speed up program loading, Apple bundles important libraries into a “shared” cache
which is located in a file (or several files) named dyld_shared_cache_<arch>.<suffix>
.
On macOS 14 - Sonoma, these shared cache files are located in /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/
:
-rwxr-xr-x 1 root admin 2.3G Aug 4 12:31 dyld_shared_cache_arm64e
-rwxr-xr-x 1 root admin 1.5G Aug 4 12:31 dyld_shared_cache_arm64e.01
-rwxr-xr-x 1 root admin 984K Aug 4 12:31 dyld_shared_cache_arm64e.map
-rwxr-xr-x 1 root admin 819M Aug 4 12:31 dyld_shared_cache_x86_64
-rwxr-xr-x 1 root admin 786M Aug 4 12:31 dyld_shared_cache_x86_64.01
-rwxr-xr-x 1 root admin 723M Aug 4 12:31 dyld_shared_cache_x86_64.02
-rwxr-xr-x 1 root admin 720M Aug 4 12:31 dyld_shared_cache_x86_64.03
-rwxr-xr-x 1 root admin 724M Aug 4 12:31 dyld_shared_cache_x86_64.04
-rwxr-xr-x 1 root admin 169M Aug 4 12:31 dyld_shared_cache_x86_64.05
-rwxr-xr-x 1 root admin 800K Aug 4 12:31 dyld_shared_cache_x86_64.map
If you already wondered where is located /usr/lib/libSystem.B.dylib
while this
library is not present on the filesystem, the answer is in the dyld shared cache.
Prior to dyld v940 (publicly released in February 2022) the dyld shared cache was composed of a single file (but one per architecture). From version 940, it is composed of several files.
One of the interesting questions about the dyld shared cache is: how do we extract or recover a library from this cache?
Indeed, the dyld shared cache encapsulates system libraries (including sensitive ones) so getting back these libraries as regular Mach-O binaries can be a legitimate question.
There are different open source tools available for that:
- https://github.com/keith/dyld-shared-cache-extractor
- https://github.com/blacktop/ipsw
- https://github.com/arandomdev/DyldExtractor
The good news is that LIEF is also supporting the shared cache with a C++/Rust/Python API (not publicly released yet).
Given a visionOS dyld shared cache directory (from Apple_Vision_Pro_2.0_22N320_Restore.ipsw
),
we can load it with LIEF through:
$ ls -l visionOS-2.0/
dyld_shared_cache_arm64e
dyld_shared_cache_arm64e.01
...
dyld_shared_cache_arm64e.57
dyld_shared_cache_arm64e.58
dyld_shared_cache_arm64e.59.dylddata
dyld_shared_cache_arm64e.60.dyldlinkedit
dyld_shared_cache_arm64e.symbols
import lief
shared_cache: lief.dyldsc.DyldSharedCache = lief.dyldsc.load("visionOS-2.0/")
From this lief.dyldsc.DyldSharedCache
object, we can iterate over the embedded dylibs with:
shared_cache: lief.dyldsc.DyldSharedCache = ...
for dylib in shared_cache.libraries:
print(f"0x{dylib.address:016x}: {dylib.path}")
0x00000001800f4000: /usr/lib/libobjc.A.dylib
0x000000018013e000: /System/Library/AccessibilityBundles/ARKit.axbundle/ARKit
0x0000000180140000: /System/Library/AccessibilityBundles/ARTraceModule.axbundle/ARTraceModule
0x0000000180142000: /System/Library/AccessibilityBundles/ASMessagesProvider.axbundle/ASMessagesProvider
...
0x0000000251766000: /usr/lib/system/libxpc.dylib
0x00000002517ad000: /usr/lib/updaters/libAce3Updater.dylib
0x00000002517d2000: /usr/lib/updaters/libAppleTCONUpdater.dylib
0x00000002517da000: /usr/lib/updaters/libAppleTypeCRetimerUpdater.dylib
0x0000000251834000: /usr/lib/updaters/libBoraUpdater.dylib
0x000000025184a000: /usr/lib/updaters/libDurantUpdater.dylib
0x0000000251854000: /usr/lib/updaters/libSEUpdater.dylib
0x00000002518c1000: /usr/lib/updaters/libSavageRestoreInfo_iOS.dylib
0x00000002518cc000: /usr/lib/updaters/libSavageUpdater_iOS.dylib
0x00000002519f0000: /usr/lib/usd/libusd_ms.dylib
0x00000002528a7000: /usr/lib/xr/libRuntimeSupport.dylib
Getting back to the original question about extracting a library from the dyld
shared cache, we can extract the in-cache Mach-O binary of /usr/lib/liblockdown.dylib
using dylib.get()
:
import lief
shared_cache: lief.dyldsc.DyldSharedCache = ...
liblockdown: lief.dyldsc.Dylib = shared_cache.find_library("/usr/lib/liblockdown.dylib")
liblockdown_macho: lief.MachO.Binary = liblockdown.get()
liblockdown_macho.write("liblockdown.1.dylib")
And we have come full circle: we can parse with LIEF shared cache files to get a
DyldSharedCache
object. From this instance, we can access the lief.dyldsc.Dylib
object associated with /usr/lib/liblockdown.dylib
. From this object, we can
retrieve a LIEF’s Mach-O binary with .get()
.
Finally, we can re-write back the Mach-O object with LIEF’s writer:
liblockdown_macho.write("liblockdown.1.dylib")
.
You can download liblockdown.1.dylib
here: romainthomas/visionOS-liblockdown/bin/liblockdown.1.dylib.
However, if we open this liblockdown.1.dylib
library in IDA or BinaryNinja
we might complain about the result:
When Apple generates the dyld shared cache, it performs some optimizations and pre-loading, such as the in-cache libraries are usually referencing addresses specific to the shared cache. These optimizations usually break:
- Relocations
- Symbol bindings
- Calls
- Got accesses
- ObjC metadata
Last but not least, on recent shared cache, the LC_DYLD_CHAINED_FIXUPS
command
is striped from the library before being added to the shared cache:
That means that we can’t rely on this command to recover the original bindings or relocations. Fortunately, we can deoptimize the in-cache library by accessing internal structures of the dyld shared cache.
All these de-optimizations can be enabled while calling the .get()
function
on a LIEF::dyldsc::Dylib
object:
#include <LIEF/DyldSharedCache.hpp>
std::unique_ptr<LIEF::MachO::Binary> extract(const LIEF::dyldsc::DyldSharedCache& cache) {
std::unique_ptr<LIEF::dyldsc::Dylib> liblockdown = cache.find_library("liblockdown.dylib");
return liblockdown.get({
.fix_branches = true,
.fix_memory = true,
.fix_relocations = true,
});
}
We can also ask LIEF to recreate a LC_DYLD_CHAINED_FIXUPS
command based on information recovered from previous stages:
#include <LIEF/DyldSharedCache.hpp>
std::unique_ptr<LIEF::MachO::Binary> extract(const LIEF::dyldsc::DyldSharedCache& cache) {
std::unique_ptr<LIEF::dyldsc::Dylib> liblockdown = cache.find_library("liblockdown.dylib");
return liblockdown.get({
.fix_branches = true,
.fix_memory = true,
.fix_relocations = true,
+ .create_dyld_chained_fixup_cmd = true,
});
}
Et voilĂ : romainthomas/visionOS-liblockdown/bin/liblockdown.2.dylib.
Using LIEF Python API it could be done with:
def extract(cache: lief.dyldsc.DyldSharedCache) -> lief.MachO.Binary:
dylib: lief.dyldsc.Dylib = cache.find("liblockdown.dylib")
return dylib.get(
fix_branches=True,
fix_memory=True,
fix_relocations=True,
create_dyld_chained_fixup_cmd=True,
)
extract(vision_os_cache).write("liblockdown.2.dylib")
You can observe the differences between the raw extracted liblockdown.1.dylib
and the de-optimized liblockdown.2.dylib
:
Now that we have a pretty well-structured liblockdown.dylib
with all
the information needed for loading (relocations, bindings, …) we can start considering
loading this visionOS library on macOS.
Loading a VisionPro library on macOS
Back in the day I was working at Quarkslab, I had the chance to create with Adrien Guinet QBDL: QuarkslaB Dynamic Linker library.
The idea of this project is to have a cross-platform/cross-format library to load executables (based on LIEF).
For the details, you can check:
From an executable format perspective only, the primary differences between Mach-O binaries for visionOS and macOS (Silicon) lie in their linked libraries and their imports.
We could fairly expect that liblockdown.dylib
from the visionOS dyld shared cache
is linked with visionOS-specific libraries or importing specific symbols.
Nonetheless, Apple is an ecosystem and they try to minimize the cost for developers to create an app or a framework that can target different platforms (visionOS/macOS/iOS/watchOS).
This comes with an API abstraction that we can leverage to lift executables from
one platform on another. In other words, we can likely expect that some libraries
used by liblockdown.dylib
for visionOS are also available on macOS.
Let’s check this with QBDL:
#include <LIEF/LIEF.hpp>
#include <QBDL/QBDL.hpp>
using namespace QBDL;
using namespace LIEF::MachO;
using namespace LIEF;
struct FinalTargetSystem: public Engines::Native::TargetSystem {
using Engines::Native::TargetSystem::TargetSystem;
uint64_t symlink(Loaders::MachO& loader, const LIEF::MachO::Symbol& symbol) override {
}
}
int main() {
auto mem = std::make_unique<Engines::Native::TargetMemory>();
auto system = std::make_unique<FinalTargetSystem>(*mem);
auto loader = Loaders::MachO::from_file("./liblockdown.2.dylib"
Engines::Native::arch(), *system, Loader::BIND::NOW
);
return 0;
}
In this code symlink
is a kind of callback that is used by QBDL whenever a symbol
needs to be resolved. For instance, you could redirect printf
with:
int my_printf(const char *restrict format, ...) {
printf("w000t");
return 5;
}
uint64_t symlink(Loaders::MachO& loader, const LIEF::MachO::Symbol& symbol) override {
if (symbol.name() == "printf") {
return (uint64_t)&my_printf;
}
return 0;
}
With our liblockdown.dylib
case, we can naively try to resolve symbols based
on the existing macOS libraries:
uint64_t symlink(Loaders::MachO& loader, const LIEF::MachO::Symbol& symbol) override {
const LIEF::MachO::DylibCommand* lib = symbol.library()
void* hdl = dlopen(lib->name().c_str(), /*mode=*/RTLD_NOW);
if (hdl == nullptr) {
fprintf(stderr, "Can't find library %s on the current macOS system\n",
lib->name().c_str());
return 0;
}
void* addr = dlsym(hdl, symbol.name().c_str());
if (addr == nullptr) {
fprintf(stderr, "Can't find '%s' in %s\n",
symbol.name().c_str(), lib->name().c_str());
return 0;
}
return (uint64_t)addr;
}
When executing this code, we can observe that the library liblockdown.dylib
from visionOS is binding 395 symbols. From these
395 symbols, only 4 of them fail to be resolved using macOS library:
Can't find '__SSLCopyPeerCertificates' in /System/Library/Frameworks/Security.framework/Security
Can't find '__SSLDisposeContext' in /System/Library/Frameworks/Security.framework/Security
Can't find '__SSLNewContext' in /System/Library/Frameworks/Security.framework/Security
Can't find '__SSLSetEnableCertVerify' in /System/Library/Frameworks/Security.framework/Security
In other words, 98% of the functions imported by liblockdown.dylib
on the visionOS
are natively available on macOS 14.
The missing symbols could be mocked or emulated but for the sake of simplicity we just skip them.
Now that we have a mean to resolve imported symbols pretty well, we can ask QBDL
to get a pointer to the function _lockdown_connect
:
struct FinalTargetSystem: public Engines::Native::TargetSystem {
using Engines::Native::TargetSystem::TargetSystem;
uint64_t symlink(Loaders::MachO& loader, const LIEF::MachO::Symbol& symbol) override {
// Logic described previously
}
}
int main() {
auto mem = std::make_unique<Engines::Native::TargetMemory>();
auto system = std::make_unique<FinalTargetSystem>(*mem);
auto loader = Loaders::MachO::from_file("./liblockdown.2.dylib"
Engines::Native::arch(), *system, Loader::BIND::NOW
);
uint64_t addr = loader->get_address("_lockdown_connect");
LK_INFO("_lockdown_connect: 0x{:016x}", addr);
return 0;
}
At this point we are in the situation where:
- We extracted
/usr/lib/liblockdown.dylib
from a visionOS dyld shared cache - We removed shared cache optimizations and we re-created a
LC_DYLD_CHAINED_FIXUPS
- We loaded the library with QBDL and most of the imported symbols are resolved
- We have a pointer to the in-macos-memory of
_lockdown_connect
Depending on the needs, we could start lldb to debug the function or feed the function with fuzzing inputs.
Since I talked about LIEF & QBDL, I can’t finish this blog post without mentioning QBDI.
Instrumenting _lockdown_connect
with QBDI
First off, we have to instantiate and initialize a QBDI’s VM2 object:
// [...]
auto loader = Loaders::MachO::from_file("./liblockdown.2.dylib"
Engines::Native::arch(), *system, Loader::BIND::NOW
);
uint64_t lockdown_connect_addr = loader->get_address("_lockdown_connect");
LK_INFO("_lockdown_connect: 0x{:016x}", lockdown_connect_addr);
QBDI::VM dbi;
QBDI::GPRState* state = vm.getGPRState();
state->pc = lockdown_connect_addr;
uint64_t start = loader->base_address();
uint64_t end = start + loader->get_binary().virtual_size();
vm.addInstrumentedRange(start, end);
I omitted stack registers initialization (sp
, x29
) but the previous code is essentially
setting the PC register and scoping the range of virtual addresses we want to instrument.
After this initialization, we can define what we want to do with the instrumented code. In this example, we just trace the instructions:
vm.addCodeCB(QBDI::InstPosition::PREINST,
[] (QBDI::VM* vm, QBDI::GPRState* gpr, QBDI::FPRState*, void* data) {
const QBDI::InstAnalysis* inst = vm->getInstAnalysis();
LK_INFO("0x{:016x}: {}", inst->address, inst->disassembly);
return QBDI::VMAction::CONTINUE;
}, nullptr
);
Let’s go?
vm.run(lockdown_connect_addr, lr);
And here we go:
_lockdown_connect: 0x00000001039ed024
0x00000001039ed024: pacibsp
0x00000001039ed028: sub sp, sp, #208
0x00000001039ed02c: stp x22, x21, [sp, #160]
0x00000001039ed030: stp x20, x19, [sp, #176]
0x00000001039ed034: stp x29, x30, [sp, #192]
0x00000001039ed038: add x29, sp, #192
0x00000001039ed03c: adrp x8, #70324224
0x00000001039ed040: ldr x8, [x8, #3352]
0x00000001039ed044: ldr x8, [x8]
0x00000001039ed048: stur x8, [x29, #-40]
0x00000001039ed04c: mov w21, #1
0x00000001039ed050: mov w0, #1
0x00000001039ed054: mov w1, #1
0x00000001039ed058: mov w2, #0
0x00000001039ed05c: bl #10372
0x00000001039ef8e0: adrp x17, #254316544
0x00000001039ef8e4: add x17, x17, #3176
0x00000001039ef8e8: ldr x16, [x17]
0x00000001039ef8ec: braa x16, x17
0x00000001039ed060: cmn w0, #1
...
Given this instruction-level granularity, we could also intercept syscall instructions.
I won’t advocate any longer about how powerful QBDI is, but for those who are interested in more details you can check these posts/publications:
- Android Native Library Analysis with QBDI
- Dynamic Binary Instrumentation Techniques to Address Native Code Obfuscation
- r2-pay - part 1 & r2-pay - part 2
Closing Words
This blog post demonstrates that it is possible to extract a library from a visionOS dyld shared cache that can be instrumented on a different platform.
While the same approach would also work for an iOS dyld shared cache, I thought it would be more challenging to tackle a recent device that lacks the same knowledge base as iOS.
Back in January 2024, dfsec also did a publication about running an iOS binary on macOS: Will macOS and iOS merge? Both approaches are complementary and I recommend reading their blog post.
For those who seek more details about QBDL/QBDI and a live demo of the instrumentation, I recorded this video where I detail more aspects about QBDL loading and QBDI instrumentation:
Finally, the source code of the PoC is on GitHub at this address: romainthomas/visionOS-liblockdown
The dyld shared cache support in LIEF is still in progress but you can join LIEF’s Discord channel to be notified when this will be released.
Thank you for reading,
Romain