在开发跨平台 C2 过程中,为了实现 MacOS 内存执行功能,我研究了相关技术并实现武器化,故有此文。
本文分以下三点介绍:
1.首先介绍 MacOS 内存执行两个失败的探索,分别是shm_open
和NSObjectFileImage
。2.接着介绍 MacOS dyld 加载流程和武器化实现。3.最后介绍 MacOS Silicon M1 会影响武器开发的几个新安全机制。
作者对于 MacOS 攻防领域属于刚入门的水平,本文如有错误还请指出。
Linux中可以通过 shm_open
创建共享内存,并通过返回的 fd 来做内存执行。然而它的效果并不好,因为能够在 /dev/shm
目录观察到文件操作行为,并且 Linux 上无法创建匿名的 shm
,所以它只是在没有 memfd
API (Linux < 3.17, glibc < 2.27) 时的后备选择。
MacOS 有 shm_open
,并且不会在文件系统体现。但 MacOS shm_open
和 /dev/fd
的抽象程度与 Linux 不同,下面是一些测试。
#include <stdio.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <dlfcn.h>
extern char **environ;
int dev_fd(char **argv) {
int fd = open("/bin/ls", O_RDONLY);
if (-1 == fd) {
perror("open");
return -1;
}
return fd;
}
int dev_fd_shm(char **argv) {
shm_unlink("ls");
int shm_fd = shm_open("ls", O_RDWR | O_CREAT | O_EXCL, 0777);
if (-1 == shm_fd) {
perror("shm_open");
return -1;
}
struct stat s = {0};
if (stat("/bin/ls", &s) == -1) {
perror("stat");
return -1;
}
if (-1 == ftruncate(shm_fd, s.st_size)) {
perror("ftruncate");
return -1;
}
void *ptr = mmap(NULL, s.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
if (!ptr || ptr == -1) {
perror("mmap");
return -1;
}
int fd = open("/bin/ls", O_RDONLY);
if (-1 == fd) {
perror("open");
return -1;
}
int n = read(fd, ptr, s.st_size);
if (-1 == n || n != s.st_size) {
perror("read");
return -1;
}
munmap(ptr, s.st_size);
close(shm_fd);
close(fd);
shm_fd = shm_open("ls", O_RDONLY);
if (shm_fd == -1) {
perror("shm_open");
return -1;
}
return shm_fd;
}
void test(int fd, char** argv) {
struct stat s = {0};
if (-1 == fstat(fd, &s)) {
perror("fstat");
return;
}
ftruncate(fd, s.st_size);
char buf[8] = {0};
int n = read(fd, buf, 8);
if (n == -1 || n != 8) {
perror("read");
printf("read %d\n", n);
}
for (int i = 0; i < 8; i++)
printf("%i ", buf[i]);
char f[64] = {0};
sprintf(f, "/dev/fd/%d", fd);
printf("%s\n", f);
if (-1 == chmod(f, 0777))
perror("chmod");
printf("dlopen %p\n", dlopen(f, RTLD_NOW));
execve(f, argv, environ);
perror("execve");
}
int main(int argc, char **argv) {
printf("===== test /dev/fd\n");
test(dev_fd(argv), argv);
printf("===== test /dev/fd shm\n");
test(dev_fd_shm(argv), argv);
}
输出为:
===== test /dev/fd
-54 -2 -70 -66 0 0 0 2 /dev/fd/3
chmod: Operation not permitted
dlopen 0x204d35460
execve: Permission denied
===== test /dev/fd shm
read: Device not configured
read -1
0 0 0 0 0 0 0 0 /dev/fd/4
chmod: Bad file descriptor
dlopen 0x0
execve: Permission denied
概括来讲,与 Linux 对比有以下限制:
1./dev/fd/<FD>
下的普通文件不能被 chmod
,不能被 exec
,可以 dlopen
2.shm_open
返回的 fd 不能被 read
(只能 mmap
后操作) ,不能被 chmod
,不能被 exec
,不能被 dlopen
综上,MacOS 上无法像 Linux 一样通过 shm_open
做内存执行。
MacOS 有一组从 macOS 10.5 已弃用[1]的 API NSCreateObjectFileImageFromMemory[2] 和 NSLinkModule[3],借用一张图
(该 API 虽然已标记弃用,但直到现在 (MacOS 12.2) 依然从 dyld 导出)
[https://slyd0g.medium.com/understanding-and-defending-against-reflective-code-loading-on-macos-e2e83211e48f]
流程为:
1.调用 NSCreateObjectFileImageFromMemory
从内存加载 bundle 文件,该函数接受一个内存 buffer 参数2.调用 NSLinkModule
将上一步创建的 NSObjectFileImage
连接到全局模块3.调用 NSLookupSymbolInModule
获取符号对象4.调用 NSAddressOfSymbol
获取符号地址
该 API 有一个限制,只能加载 bundle 文件。
NSObjectFileImageReturnCode APIs::NSCreateObjectFileImageFromMemory(const void* memImage, size_t memImageSize, NSObjectFileImage* ofi)
{
...
// this API can only be used with bundles
if ( !mf->isBundle() ) {
return NSObjectFileImageInappropriateFile;
}
...
}
MacOS 的 bundle 文件[4]有几种不同含义:打包的 APP Bundle、Framework Bundle 和 Loadable Bundle。此处为 Loadable Bundle,该文件格式与 dylib 相同。
在 Intel 芯片的 MacOS 上使用该 API 加载 dylib 或 executable 只需修改 Mach-O 文件头的 filetype(M1 芯片无法通过改文件头的方式实现,原因留到后文讲)。
编写一个简单的 loader ,再编译 x86 的 dylib 和 bundle 文件进行测试:
#include <mach-o/dyld.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <stdlib.h>
int main(int argc, char** argv) {
int fd = open(argv[1], O_RDONLY);
if (fd == -1) {
perror("open");
return -1;
}
struct stat s = { 0 };
if (-1 == fstat(fd, &s)) {
perror("fstat");
return -1;
}
char* buf = malloc(s.st_size);
if (!buf) {
perror("malloc");
return -1;
}
int total = 0;
do {
int n = read(fd, buf + total, s.st_size - total);
if (n == -1)
break;
total += n;
} while (total < s.st_size);
if (total != s.st_size) {
perror("read");
return -1;
}
((struct mach_header_64*)buf)->filetype = MH_BUNDLE;
NSObjectFileImage img;
NSObjectFileImageReturnCode c = NSCreateObjectFileImageFromMemory(buf, s.st_size, &img);
if (c != NSObjectFileImageSuccess) {
printf("NSCreateObjectFileImageFromMemory %d\n", c);
return -1;
}
NSModule mod = NSLinkModule(img, "_", NSLINKMODULE_OPTION_NONE);
if (mod == -1) {
perror("NSLindModule");
return -1;
}
printf("mod %p\n", mod);
NSSymbol sym = NSLookupSymbolInModule(mod, "_foo");
if (sym == -1) {
perror("NSLookupSymbolInModule");
return -1;
}
void* addr = NSAddressOfSymbol(sym);
printf("%p\n", addr);
((void (*)())(addr))();
}
在最初研究时,我逆了 CrossC2 想要学习它内存加载的实现,发现它是通过上文 NSObjectFileImage
的方式做的,但随着我挖掘 Cross C2 Beacon 动态特征时却发现了下面的行为:
[cc2_mem]: delete executable file backup in memory!
[cc2_mem]: run executable file from memory!
[runExec]: add jobInfo-> cc2_frp, PID-> 41844
2022-07-06 17:27:10 - Created file: /private/var/folders/xx/6qkl26212b50t_twbbk51rhw0000gn/T/NSCreateObjectFileImageFromMemory-Ay24wFNH
2022-07-06 17:27:10 - Modified directory: /private/var/folders/xx/6qkl26212b50t_twbbk51rhw0000gn/T
2022-07-06 17:27:10 - Modified file: /private/var/folders/xx/6qkl26212b50t_twbbk51rhw0000gn/T/NSCreateObjectFileImageFromMemory-Ay24wFNH
2022-07-06 17:27:10 - Modified file: /private/var/folders/xx/6qkl26212b50t_twbbk51rhw0000gn/T/NSCreateObjectFileImageFromMemory-Ay24wFNH
2022-07-06 17:27:10 - Deleted file: /private/var/folders/xx/6qkl26212b50t_twbbk51rhw0000gn/T/NSCreateObjectFileImageFromMemory-Ay24wFNH
2022-07-06 17:27:10 - Modified directory: /private/var/folders/xx/6qkl26212b50t_twbbk51rhw0000gn/T
NSObjectFileImage
会在 TMPDIR 下落盘一个 NSCreateObjectFileImageFromMemory-XXXXXXXX
的临时文件。这说明 NSObjectFileImage
并不是内存加载,而会触碰 tmpfs 并存在强特征,也就导致用 NSObjectFileImage
的效果甚至不如自己落盘一个随机文件名的 Mach-O 文件到 /tmp
然后 dlopen
或 execve
。
查看 dyld 源码中 dyld4::NSLinkModule
的逻辑,发现了相关逻辑。它通过向 $TMPDIR
目录创建 NSCreateObjectFileImageFromMemory-XXXXXXXX
格式的临时文件,并写入 image 的内容,最后通过 dlopen
加载。
NSModule APIs::NSLinkModule(NSObjectFileImage ofi, const char* moduleName, uint32_t options)
{
...
if ( ofi->memSource != nullptr ) {
// make temp file with content of memory buffer
ofi->path = nullptr;
char tempFileName[PATH_MAX];
const char* tmpDir = this->libSystemHelpers->getenv("TMPDIR");
if ( (tmpDir != nullptr) && (strlen(tmpDir) > 2) ) {
strlcpy(tempFileName, tmpDir, PATH_MAX);
if ( tmpDir[strlen(tmpDir) - 1] != '/' )
strlcat(tempFileName, "/", PATH_MAX);
}
else
strlcpy(tempFileName, "/tmp/", PATH_MAX);
strlcat(tempFileName, "NSCreateObjectFileImageFromMemory-XXXXXXXX", PATH_MAX);
int fd = this->libSystemHelpers->mkstemp(tempFileName);
if ( fd != -1 ) {
ssize_t writtenSize = ::pwrite(fd, ofi->memSource, ofi->memLength, 0);
if ( writtenSize == ofi->memLength ) {
ofi->path = (char*)this->libSystemHelpers->malloc(strlen(tempFileName)+1);
::strcpy((char*)(ofi->path), tempFileName);
}
else {
//log_apis("NSLinkModule() => NULL (could not save memory image to temp file)\n");
}
::close(fd);
}
// <rdar://74913193> support old licenseware plugins
openMode = RTLD_UNLOADABLE | RTLD_NODELETE;
}
...
// dlopen the binary outside of the read lock as we don't want to risk deadlock
ofi->handle = dlopen(ofi->path, openMode);
if ( ofi->handle == nullptr ) {
if ( config.log.apis )
log("NSLinkModule(%p, %s) => NULL (%s)\n", ofi, moduleName, dlerror());
return nullptr;
}
...
}
但实际上我注意到这项内存加载技术已经存在非常久了,不大可能从来没人发现这个缺陷,因此我猜测是内部实现变了。接着我 clone 了 dyld 仓库,尝试在不同的 branch 中寻找这个临时文件名:
import os
os.system('git tag --sort=v:refname > /tmp/tags')
tags = open('/tmp/tags').readlines()
os.chdir('/Users/x/Desktop/ld-test/dyld')
for t in tags:
print('=========', t)
os.system('git checkout ' + t)
os.system('grep -rn NSCreateObjectFileImageFromMemory-')
输出结果为:
...
========= dyld-433.5
Previous HEAD position was 7691c4c dyld-421.2
HEAD is now at bd2e880 dyld-433.5
========= dyld-519.2.1
Previous HEAD position was bd2e880 dyld-433.5
HEAD is now at 628c97f dyld-519.2.1
./dyld3/APIs_macOS.cpp:153: ofi->path = ::tempnam(nullptr, "NSCreateObjectFileImageFromMemory-");
./testing/test-cases/NSCreateObjectFileImageFromMemory-basic.dtest/main.c:3:// BUILD: $CC main.c -o $BUILD_DIR/NSCreateObjectFileImageFromMemory-basic.exe -Wno-deprecated-declarations
./testing/test-cases/NSCreateObjectFileImageFromMemory-basic.dtest/main.c:6:// RUN: ./NSCreateObjectFileImageFromMemory-basic.exe $RUN_DIR/foo.bundle
./testing/test-cases/NSCreateObjectFileImageFromMemory-basic.dtest/main.c:103: printf("[BEGIN] NSCreateObjectFileImageFromMemory-basic\n");
./testing/test-cases/NSCreateObjectFileImageFromMemory-basic.dtest/main.c:112: printf("[PASS] NSCreateObjectFileImageFromMemory-basic\n");
Binary file ./.git/index matches
...
显而易见,临时文件名是从 dyld-519.2.1 才开始出现的,那么回到 dyld-433.5 看看当时是怎么实现的:
NSObjectFileImageReturnCode NSCreateObjectFileImageFromMemory(const void* address, size_t size, NSObjectFileImage *objectFileImage)
{
...
ImageLoader* image = dyld::loadFromMemory((const uint8_t*)address, size, NULL);
if ( ! image->isBundle() ) {
// this API can only be used with bundles...
dyld::garbageCollectImages();
return NSObjectFileImageInappropriateFile;
}
// Note: We DO NOT link the image! NSLinkModule will do that
if ( image != NULL ) {
*objectFileImage = createObjectImageFile(image, address, size);
return NSObjectFileImageSuccess;
}
}
...
return NSObjectFileImageFailure;
}
ImageLoader* loadFromMemory(const uint8_t* mem, uint64_t len, const char* moduleName)
{
// if fat wrapper, find usable sub-file
const fat_header* memStartAsFat = (fat_header*)mem;
uint64_t fileOffset = 0;
uint64_t fileLength = len;
if ( memStartAsFat->magic == OSSwapBigToHostInt32(FAT_MAGIC) ) {
if ( fatFindBest(memStartAsFat, &fileOffset, &fileLength) ) {
mem = &mem[fileOffset];
len = fileLength;
}
...
}
// try each loader
if ( isCompatibleMachO(mem, moduleName) ) {
ImageLoader* image = ImageLoaderMachO::instantiateFromMemory(moduleName, (macho_header*)mem, len, gLinkContext);
// don't add bundles to global list, they can be loaded but not linked. When linked it will be added to list
if ( ! image->isBundle() )
addImage(image);
return image;
}
// try other file formats here...
// throw error about what was found
switch (*(uint32_t*)mem) {
case MH_MAGIC:
case MH_CIGAM:
case MH_MAGIC_64:
case MH_CIGAM_64:
throw "mach-o, but wrong architecture";
default:
throwf("unknown file type, first eight bytes: 0x%02X 0x%02X 0x%02X 0x%02X 0x%02X 0x%02X 0x%02X 0x%02X",
mem[0], mem[1], mem[2], mem[3], mem[4], mem[5], mem[6],mem[7]);
}
}
NSCreateObjectFileImageFromMemory
中调用 dyld::loadFromMemory
做了简单的解析校验工作,然后创建了 ImageLoader
,整个过程是纯内存的。
NSModule NSLinkModule(NSObjectFileImage objectFileImage, const char* moduleName, uint32_t options)
{
...
// support private bundles
if ( (options & NSLINKMODULE_OPTION_PRIVATE) != 0 )
objectFileImage->image->setHideExports();
// set up linking options
bool forceLazysBound = ( (options & NSLINKMODULE_OPTION_BINDNOW) != 0 );
// load libraries, rebase, bind, to make this image usable
dyld::link(objectFileImage->image, forceLazysBound, false, ImageLoader::RPathChain(NULL,NULL), UINT32_MAX);
// bump reference count to keep this bundle from being garbage collected
objectFileImage->image->incrementDlopenReferenceCount();
// run initializers unless magic flag says not to
if ( (options & NSLINKMODULE_OPTION_DONT_CALL_MOD_INIT_ROUTINES) == 0 )
dyld::runInitializers(objectFileImage->image);
return ImageLoaderToNSModule(objectFileImage->image);
...
}
NSLinkModule
调用 dyld::link
,其内部调用 ImageLoader::link
,完成了 Image 的加载,接着调用 ctor 函数。
总结一下,在 dyld-433.5 及以前的 dyld 是纯内存加载的,从 dyld-5 开始(大概在 2017.9),NSObjectFileImage
已经成为攻防领域一项死去的技术。
上述两项技术探索失败意味着只能通过实现 Mach-O 加载流程来实现内存加载,所以我对照 dyld 实现了一个 Mach-O Loader,可以兼容 Intel / M1 芯片系统和 MacOS 12 以上和以下。
武器化实现 Mach-O Loader 的流程:(在看之前建议先学习 Mach-O 文件的格式[5] )
1.解析所有的 LC_LOAD_DYLIB
,加载所需 dylib。2.遍历 LC_SEGMENT(_64)
计算大小,调用 vm_allocate
为所有 segments 分配一块连续的内存。3.遍历 LC_SEGMENT(_64)
将所有 segments 映射到第二步的内存中。4.在 MacOS 12 以下,解析 LC_DYLD_INFO_ONLY
和 LC_DYLD_INFO
,处理 rebase、bind、weak_bind 和 lazy_bind 的 bytecodes。5.在 MacOS 12 以上,解析 LC_DYLD_CHAINED_FIXUPS
,处理 DYLD_CHAINED_IMPORT*
和 DYLD_CHAINED_PTR*
。6.初始化 TLV,处理 tlv descriptors。7.调用 vm_protect
恢复 segments 的保护属性。8.遍历寻找带有 S_INIT_FUNC_OFFSETS
或 S_MOD_INIT_FUNC_POINTERS
的 sections,调用其中的 Initializer
函数。
第 4. 5. 步中,MacOS 12 & iOS 15 以上和以下采用了两种不同的数据结构记录 rebase 和 bind 信息。MacOS 12 & iOS 15 以下使用 bytecodes 结构来存储,解析过程可见 ImageLoaderMachOCompressed::eachBind[6]。MacOS 12 & iOS 15 以上存储在一个叫 fixup chains 的链表结构,具体解析过程可参考 LIEF[7]。
第 6. 8. 中处理 TLS 和 Initializer
,与 Windows 不同,CRT 中 TLS 的初始化在 MacOS 上是在 Initializer
中做的,而不像 Windows 会在 main / DllMain
外包一层 CRTStartup
。
在集成到 C2 时我使用了先 fork
后执行的方法。其一,因为 Windows 和 nix 的进程创建逻辑不同,nix 上执行命令是通过 fork
+ exec
的方式实现,所以这里的 fork
并不是敏感行为。其二,也就是 CS fork & run
的优点:更稳定和更容易捕获输出。
对 Mach-O Loader
的测试分为以下几项:
1.MacOS 12
以上的 iox.dylib
,区分 amd64
/ arm64
。
2.MacOS 12
以上的 hack-browser-data.dylib
,区分 amd64
/ arm64
。
3.MacOS 12
以下因为没有现成环境,借用 CrossC2 Kit
里的 crossc2_frp.dylib
、cc2_safari_dump.dylib
和 cc2_keylogger.dylib
。
因为这个 Loader 一方面被集成在 C2 中,另一方面还被用作 MacOS 加壳免杀,所以暂时不考虑开源。
M1 引入了代码强制性签名,签名校验失败将无法执行程序或加载库。
下面演示修改数据段的一个字节会导致进程直接被 kill,同样的,加载一个被 patch 而导致签名异常的 dylib 也会失败。
但我测试的时候发现此限制仅针对于 M1 中的原生 ARM 程序,跑在模拟器里的 x86 程序则无限制。
使用 codesign 查看签名,x86 程序编译时不会生成签名,也就无从校验。顺着这个思路,尝试删除 arm64 程序的签名,但依然无法执行。
目前找到的唯一办法是进入保护模式关闭 SIP,这在实战中几乎没有可操作性,因此这个限制会导致:
1.某些工具的生成无法通过 patch 二进制的方式。2.前文 NSObjectFileImage
加载需要 patch 文件头格式为 bundle 也无法再实现。
M1 芯片系统不允许创建 WX 或 RWX 的内存块,如下:
上图中 READ 为1,WRITE 为2,EXEC 为4,返回值0为 KERN_SUCCESS
,2为 KERN_PROTECTION_FAILURE
。可见 WX 和 RWX 会导致 vm_protect
返回失败。另一点,和 code sign 类似,M1 下跑在模拟器里的 x86 程序无此限制。
[1]
macOS 10.5 已弃用: https://github.com/apple-oss-distributions/dyld/blob/main/libdyld/libdyldGlue.cpp#L214[2]
NSCreateObjectFileImageFromMemory: https://github.com/apple-oss-distributions/dyld/blob/main/dyld/DyldAPIs.cpp#L2727[3]
NSLinkModule: https://github.com/apple-oss-distributions/dyld/blob/main/dyld/DyldAPIs.cpp#L2788[4]
bundle 文件: https://en.wikipedia.org/wiki/Bundle_(macOS)[5]
Mach-O 文件的格式: https://evilpan.com/2020/09/06/macho-inside-out/[6]
ImageLoaderMachOCompressed::eachBind: https://github.com/opensource-apple/dyld/blob/3f928f32597888c5eac6003b9199d972d49857b5/src/ImageLoaderMachOCompressed.cpp#L934[7]
LIEF: https://github.com/lief-project/LIEF/blob/master/src/MachO/ChainedFixup.cpp