codesign --remove-signature 的 bug 解决

听说在 macOS 上使用自带的 codesign 来移除应用程序签名的话,会使得 MachO 文件变得不正常,于是就花了点时间来看看 codesign 相关的源代码。虽然有第三方的工具,自己也可以通过简单粗暴的改 MachO 文件来解决,但是探究问题具体出在哪里也很有趣。那么这里将以 Reeder 为例子(其实存在感不强233)讲述。

Malformed MachO After Removed Signature
Malformed MachO After Removed Signature

同样的,在前面我也列出目录,你可以直接跳到解决方案的部分。

0x01 - codesign.cpp

首先,既然我们是调用的 codesign --remove-signature,好在 Apple 是非常开放的,我们可以在 opensource.apple.com 获得 codesign 的源代码,codesign.cpp。不过最新的 codesign 似乎不在 security_systemkeychain 里面了。总之先从 codesign 的源代码开始看起吧。

和我们去除代码签名相关的部分则是

const struct option options[] = {
...
    { "remove-signature", no_argument,NULL, optRemoveSignature },
...
}
int main(int argc, char *argv[])
{
...
    case optRemoveSignature:
        signerName = NULL;
        operation = doSign; // well, un-sign
        break;
        ...
    }
...
    switch (operation) {
        case doSign:
            prepareToSign();
            break;
        ...
    }
...
    switch (operation) {
        case doSign:
            sign(target);
            break;
        ...
    }
}

嗯,很清晰的结构,如果是调用的 codesign --remove-signature MachOFilePath 的话,就将 operation 记为 doSign (well, un-sign, 233),然后就是准备[去掉]签名,最后[去掉]签名。唯一的麻烦就是这里的 prepareToSign() 和 sign() 函数都不是在 codesign.cpp 里的。

0x02 - cs_sign.cpp

于是查看 codesign.cpp 所在的目录 security_systemkeychain -55202/src,发现对应的函数是在 cs_sign.cpp 里面。

里面检查了不少参数,但是我们重点关注的是

static CFMutableDictionaryRef parameters;// common signing parameters
static SecCodeSignerRef signerRef;// global signer object

void prepareToSign()
{
    ...
    if (signer)
        CFDictionaryAddValue(parameters,
                             kSecCodeSignerIdentity, signer);
    else
        flags |= kSecCSRemoveSignature;
    ...
    MacOSError::check(SecCodeSignerCreate(parameters, flags, &signerRef));
    ...
}

//
// Sign a code object.
//
void sign(const char *target)
{
    ...
    // add target-specific signing inputs, mostly carried from a prior signature
    CFCopyRef<SecCodeSignerRef> currentSigner = signerRef;// the one we prepared during setup

    // do the deed
    ErrorCheck check;
    check(SecCodeSignerAddSignatureWithErrors(currentSigner, code, kSecCSDefaultFlags, check));
    ...
}

一个就是 SecCodeSignerCreate, 另一个是 SecCodeSignerAddSignatureWithErrors。

0x03 - SecCodeSigner.cpp

从命名上想必也能猜到它们是 Security.framework 中的函数,于是跳到 Security.framework 的代码里,它们都在 SecCodeSigner.cpp 里。

SecCodeSignerCreate 检查参数,然后创建了一个 SecCodeSigner 的对象,用于 CodeSigning,这里我们正好是要去掉代码签名,所以 SecCodeSigner 初始化时没有做更多的工作。

接下来在 sign() 函数中,我们关心的是 SecCodeSignerAddSignatureWithErrors。它也很简单,就直接传入刚才的 SecCodeSigner 的引用,然后让它签名。接下来便是

//
// Remove any existing code signature from code
//
void SecCodeSigner::Signer::remove(SecCSFlags flags)
{
    // can't remove a detached signature
    if (state.mDetached)
        MacOSError::throwMe(errSecCSNotSupported);

    rep = code->diskRep();
    if (Universal *fat = state.mNoMachO ? NULL : rep->mainExecutableImage()) {
        // architecture-sensitive removal
        MachOEditor editor(rep->writer(), *fat, digestAlgorithms(), rep->mainExecutablePath());
        editor.allocate();// create copy
        editor.commit();// commit change
    } else {
        // architecture-agnostic removal
        RefPointer<DiskRep::Writer> writer = rep->writer();
        writer->remove();
        writer->flush();
    }
}

于是又来到了

void MachOEditor::allocate()
{
    // note that we may have a temporary file from now on (for cleanup in the error case)
    mTempMayExist = true;

    // run codesign_allocate to make room in the executable file
    fork();
    wait();
    if (!Child::succeeded())
        MacOSError::throwMe(errSecCSHelperFailed);

    // open the new (temporary) Universal file
    {
        UidGuard guard(0);
        mFd.open(tempPath, O_RDWR);
    }
    mNewCode = new Universal(mFd);
}

这里居然 fork() 了,于是查看 Child 类,终于看到了尽头的样子

void MachOEditor::childAction()
{
    vector<const char *> arguments;
    arguments.push_back(helperName);
    arguments.push_back("-i");
    arguments.push_back(sourcePath.c_str());
    arguments.push_back("-o");
    arguments.push_back(tempPath.c_str());

    for (Iterator it = architecture.begin(); it != architecture.end(); ++it) {
        size_t size = LowLevelMemoryUtilities::alignUp(it->second->blobSize, csAlign);
        char *ssize;// we'll leak this (execv is coming soon)
        asprintf(&ssize, "%zd", size);

        if (const char *arch = it->first.name()) {
            CODESIGN_ALLOCATE_ARCH((char*)arch, (unsigned int)size);
            arguments.push_back("-a");
            arguments.push_back(arch);
        } else {
            CODESIGN_ALLOCATE_ARCHN(it->first.cpuType(), it->first.cpuSubtype(), (unsigned int)size);
            arguments.push_back("-A");
            char *anum;
            asprintf(&anum, "%d", it->first.cpuType());
            arguments.push_back(anum);
            asprintf(&anum, "%d", it->first.cpuSubtype());
            arguments.push_back(anum);
        }
        arguments.push_back(ssize);
    }
    arguments.push_back(NULL);

    if (mHelperOverridden)
        ::csops(0, CS_OPS_MARKKILL, NULL, 0);// force code integrity
    (void)::seteuid(0);// activate privilege if caller has it; ignore error if not
    execv(mHelperPath, (char * const *)&arguments[0]);
}

所以用 codesign 去掉签名,主要还是让 codesign_allocate 来做工作。那么我们的 codesign --remove-signature Reeder,在这里实际会执行 codesign_allocate -i Reeder -a x86_64 0 -o Reeder.cstemp

0x04 - codesign_allocate.c

那我们来看看 codesign_allocate 吧,毕竟,前面说了这么多,还没有发现任何可能导致 load_command 中文件长度不正确的代码。codesign_allocate.c 中(我们获取的是最新的 Xcode 8.2.1 中的 codesign_allocate,它包含在 cctools - 895 里,这样可以保证我们如果改了源代码使问题解决了的话,就说明的确是这一部分的问题),和我们目前关心的问题有关系的有这一部分

...
    else if(strcmp(argv[i], "-a") == 0){
        if(i + 2 == argc){
            error("missing argument(s) to: %s option", argv[i]);
            usage();
        } else {
            arch_signs = reallocate(arch_signs, (narch_signs + 1) * sizeof(struct arch_sign));
            if(get_arch_from_flag(argv[i+1], &(arch_signs[narch_signs].arch_flag)) == 0){
                error("unknown architecture specification flag: " "%s %s %s", argv[i], argv[i+1], argv[i+2]);
                arch_usage();
                usage();
            }
            arch_signs[narch_signs].datasize = strtoul(argv[i+2], &endp, 0);
            if(*endp != '\0')
                fatal("size for '-a %s %s' not a proper number", argv[i+1], argv[i+2]);
            if((arch_signs[narch_signs].datasize % 16) != 0)
                fatal("size for '-a %s %s' not a multiple of 16", argv[i+1], argv[i+2]);
            arch_signs[narch_signs].found = FALSE;
            narch_signs++;
            i += 2;
        }
    }
...

这里接受了 arch_signs[narch_signs].datasize 的数值,然后验证了是否 16 字节对齐。之后便是

...
    checkout(archs, narchs);
    process(archs, narchs);

    for(i = 0; i < narch_signs; i++){
        if(arch_signs[i].found == FALSE)
            fatal("input file: %s does not contain a matching architecture " "for specified '-a %s %u' option", input, arch_signs[i].arch_flag.name, arch_signs[i].datasize);
    }

    writeout(archs, narchs, output, 0777, TRUE, FALSE, FALSE, FALSE, NULL);
...

在 process(archs, narchs) 中(确切的说,是它所调用的setup_code_signature(archs + i, NULL, archs[i].object))包含了大量的计算,不过厘清之后,实际上只有这一小段是可能密切相关的

/*
* If this has a code signature load command reuse it and just change
* the size of that data.  But do not use the old data.
*/
if(object->code_sig_cmd != NULL){
    if(object->seg_linkedit != NULL){
        object->seg_linkedit->filesize += arch_signs[i].datasize - object->code_sig_cmd->datasize;
        if(object->seg_linkedit->filesize > object->seg_linkedit->vmsize)
            object->seg_linkedit->vmsize = rnd(object->seg_linkedit->filesize, get_segalign_from_flag(&arch_signs[i].arch_flag));
    }
    else if(object->seg_linkedit64 != NULL){
        object->seg_linkedit64->filesize += arch_signs[i].datasize;
        object->seg_linkedit64->filesize -= object->code_sig_cmd->datasize;
        if(object->seg_linkedit64->filesize > object->seg_linkedit64->vmsize)
            object->seg_linkedit64->vmsize = rnd(object->seg_linkedit64->filesize,
        get_segalign_from_flag(&arch_signs[i].arch_flag));
    }

    object->code_sig_cmd->datasize = arch_signs[i].datasize;
    object->output_code_sig_data_size = arch_signs[i].datasize;
    object->output_code_sig_data = NULL;

    object->output_sym_info_size = rnd(object->output_sym_info_size, 16);
    object->output_sym_info_size += object->code_sig_cmd->datasize;
}

在检查之后你会发现这一段也并没有错误,简单来说,就是从 __LINKEDIT 段的文件大小中减去原来的签名的大小,然后加上现在签名的大小。于是只能将目光转向最后一个坑了,writeout 函数。

0x05 - writeout.c

writeout 函数也是在 cctools 中,位于 libstuff。writeout 函数主要调用了 writeout_to_mem,先在内存中修改,然后写入到文件。然而这里面的计算也是相当多,但是似乎也没有计算出错的地方。

既然静态分析看起来已经不现实了(毕竟靠人力慢慢去验证这么大量的计算部分还是很累的,而且出错率也不会低),于是尝试将 codesign_allocate 编译起来调试。

进入 cctools 目录,然后二话不说就开始 make,结果报错了。

鉴于 LLVM 的坑实在太大,于是在 Makefile 中关掉了 LLVM 的 LTO 支持,再次编译,结果又报错。

OFILE_LLVM_BITCODE 没有声明,干脆在 Makefile 中定义成 0 好了。

……还是看看 codesign_allocate 需要哪些吧,有些不必要的就不编译了。

在看了 codesign_allocate 的源代码和引入的头文件之后,编译 codesign_allocate 的工作就变成了如下步骤。

0x06 - 解决方案

首先下载 cctools(这里给出的链接是 Xcode 8.2.1 中 codesign_allocate 所对应的 cctools 895),解压进入 cctools 目录下的 libstuff。

编辑 Makefile,注释掉 LTO 支持的那行

#LTO = -DLTO_SUPPORT

然后仍然是在 libstuff 目录下,编译 libstuff

$ make

编译完 libstuff 之后,回到 cctools 目录中,编译 consign_allocate

$ clang -I./include -L./libstuff -lstuff misc/codesign_allocate.c -o codesign_allocate

编译完之后,我们再来试试,这里就直接用

./codesign_allocate -i /Applications/Reeder.app/Contents/MacOS/Reeder -a x86_64 0 -o /Applications/Reeder.app/Contents/MacOS/Reeder.cstemp_2

0x07 - Xcode 自带的 codesign_allocate 到底对 MachO 做了什么

为了对比先前的 codesign_allocate,这里我还调用了

/usr/bin/codesign_allocate -i /Applications/Reeder.app/Contents/MacOS/Reeder -a x86_64 0 -o /Applications/Reeder.app/Contents/MacOS/Reeder.cstemp_1

可以看到,这里两个 codesign_allocate 出来的 MachO 大小不一样,我们刚才编译的那个生成的文件是 /Applications/Reeder.app/Contents/MacOS/Reeder.cstemp_2,长度是 5746352 字节,恰好就是这篇 post 一开头 dyld 找到的 __LINKEDIT 段所 map 的文件长度。

那么这两个到底是哪里不一样呢?我们先用 MachOView 来看看。先是原始 MachO 的信息

原始 MachO 的 mach_header_64
原始 MachO 的 mach_header_64

在 mach_header 中,可以看到原始的 MachO 文件一共有 33 个 load_command,大小为 4680 字节。

原始 MachO 的 __LINKEDIT 段
原始 MachO 的 __LINKEDIT 段

原始 MachO 的 __LINKEDIT 段文件偏移为 0x00553000 (5582848),长度是 0x0003C530 (247088),那么加在一起就是 5582848 + 247088 = 5829936,的确在上面 ls 的结果中,原始 MachO 文件的大小为 5829936 字节。

原始 MachO 的 LC_CODE_SIGNATURE
原始 MachO 的 LC_CODE_SIGNATURE

原始 MachO 的 LC_CODE_SIGNATURE 里,偏移是 0x0057AEB0 (5746352),长度是 0x00014680 (83584),相加起来也是 5829936。

我们编译的 codesign_allocate 生成的 MachO 的信息如下,

自己编译的 codesign_allocate 生成的 MachO 的 mach_header_64
自己编译的 codesign_allocate 生成的 MachO 的 mach_header_64

这里相比原始 MachO 文件并没有改变,仍然是一共有 33 个 load_command,大小为 4680 字节。

自己编译的 codesign_allocate 生成的 MachO 的 __LINKEDIT 段
自己编译的 codesign_allocate 生成的 MachO 的 __LINKEDIT 段

这边可以看到__LINKEDIT 段文件偏移还是 0x00553000 (5582848),但是长度是 0x00027EB0 (163504),加起来的话,5582848 + 163504 = 5746352。同样的,上面 ls 的结果中可以看到 Reeder.cstemp_2 的大小为 5746352 字节。

自己编译的 codesign_allocate 生成的 MachO 的 LC_CODE_SIGNATURE
自己编译的 codesign_allocate 生成的 MachO 的 LC_CODE_SIGNATURE

这里的 LC_CODE_SIGNATURE 还在,但是签名的长度已经被设置为 0 了。可以验证,原始 MachO 文件的长度是 5829936,其签名大小为 83584,两者相减,5829936 - 83584 = 5746352。

那么自带的 codesign_allocate 所生成的 MachO 呢?

Xcode 自带的 codesign_allocate 生成的 MachO 的 mach_header_64
Xcode 自带的 codesign_allocate 生成的 MachO 的 mach_header_64

这里 Xcode 自带的 codesign_allocate 将 load_command 的数量减少了 1,大小自然也变为了 4664 字节。(LC_CODE_SIGNATURE 占用 16 字节)

Xcode 自带的 codesign_allocate 生成的 MachO 的 __LINKEDIT 段
Xcode 自带的 codesign_allocate 生成的 MachO 的 __LINKEDIT 段

Xcode 自带的 codesign_allocate 所生成的 MachO 的 __LINKEDIT 段和我们自己编译的 codesign_allocate 所生成的一样。都是文件偏移保持 0x00553000 (5582848),但是长度变为 0x00027EB0 (163504)。

Xcode 自带的 codesign_allocate 生成的 MachO 的 LC_CODE_SIGNATURE
Xcode 自带的 codesign_allocate 生成的 MachO 的 LC_CODE_SIGNATURE

因为彻底删掉了 LC_CODE_SIGNATURE,所以也就找不到了。

到目前为止,Xcode 自带的 codesign_allocate 看上去完全没有错,但是事实上,我们知道 Xcode 自带的 codesign_allocate 所生成的实际的文件变小了,于是只好上二进制对比工具,看看到底是哪里少了那几个字节。

我们先对比原始 MachO 和我们刚才编译的 codesign_allocate 所生成的文件。

原始 MachO 与我们自己编译的 codesign_allocate 所生成的 MachO 文件对比
原始 MachO 与我们自己编译的 codesign_allocate 所生成的 MachO 文件对比

可以看到,一共有 3 处不同,按照偏移量顺序来说的话,第一处是修改了 __LINKEDIT 段的长度,第二处是 LC_CODE_SIGNATURE 所记录的 data size,第三处显然是代码签名。

那么 Xcode 自带的 codesign_allocate 所生成的 MachO 呢?

原始 MachO 与 Xcode 自带的 codesign_allocate 所生成的 MachO 文件对比
原始 MachO 与 Xcode 自带的 codesign_allocate 所生成的 MachO 文件对比

这里一共有 4 处不同,同样按照偏移量顺序来说的话,第一处是 mach_header_64 中所记录的 load_command 的数量,第二处是 mach_header_64 中所记录的 load_command 的大小,第三处是 __LINKEDIT 段记录的长度,第四处则是代码签名。

等等!为什么这里代码签名被多删掉了 4 个字节?我们编译的 codesign_allocate 删代码签名时,是从 FA DE 0C C0 开始的,而 Xcode 自带的则是 00 00 00 00 FA DE 0C C0。这多删掉的 4 个字节,恰好也是前面 dyld 抱怨实际文件大小与 __LINKEDIT 段指定的文件大小的差。

我们使用的是开源的、 Xcode 8.2.1 中 cctools 代码, 仅仅关掉了 LLVM 的 LTO 支持,就使得 codesign_allocate 在移除代码签名时正常工作了,我们可以合理的推测,之前移除代码签名之后,dyld 抱怨生成的 MachO 是不合法的元凶,就是 LLVM 的 LTO 支持。在 writeout.c 中,的的确确也有两处利用这个宏开关控制的部分。这个猜想应该是合理的,至于 LLVM 的 LTO 支持在哪里出了问题,这个暂时还不清楚。

Leave a Reply

Your email address will not be published. Required fields are marked *

three × 2 =