一、前言

近期,符号链接在本地提权利用中的比重逐渐增加。无论是macOS、Windows、Linux操作系统,还是其第三方安装的应用程序,都可能使用符号链接。越是底层的函数调用,开发者越需要关注相关漏洞模式及函数参数传递的安全性。

一个漏洞的产生通常是由于在使用API函数时未充分考虑潜在的漏洞模式。如果在执行过程中存在恶意行为对这些函数进行修改,防护措施不严格会导致执行流程走向错误的分支,最终被攻击者利用。下面将以openrename两个基础函数为例,介绍多个相关漏洞。

二、函数介绍

2.1 OPEN函数

open函数是C语言中的一个基础函数,主要用于打开文件并返回一个文件描述符。其他函数可以通过这个文件描述符对文件或目录进行读写操作。其函数原型如下:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

open函数存在三个参数:

  • mode: 可以在调用时省略第三个参数mode,在创建新文件的时候,会通过此参数修改新文件的文件属性;
  • pathname: 用于一个指向文件或者文件夹的路径,可以为绝对路径也可以为相对路径;
  • flag: 作为open函数最重要的一环,参数flag能赋予的值有各种,不同的flag字段代表着不同的功能。

2.2 RENAME函数

rename函数作为C语言中的一个基础函数,主要用于处理文件或目录的移动和重命名。该函数不会修改文件或目录的属性。其函数原型如下:

#include <stdio.h>

int rename(const char *oldpath, const char *newpath);

rename函数只有两个参数,一个是指向旧文件的字符串指针,另一个是指向想要修改后的新文件字符串指针。无论是哪一个参数,它都不会去检测访问路径是否是符号链接,即目录是通过符号链接访问的依旧会执行。反之,若是指向的最终文件/目录是符号链接,rename不会对其访问,只有在访问过程中的目录路径属于符号链接时才会进入。

三、行为检测与漏洞分析

通常,对于系统调用号的检测,在Linux上有成熟的方法。在macOS上,也有专门用于监控文件行为的系统命令fs_usagefs_usage可以实时监听系统用户态上对文件的各种操作行为,包括文件状态获取、读写操作以及页面交换等。通过使用fs_usage,开发者和安全研究人员能够详细地跟踪和分析文件相关的系统活动,从而更好地理解和诊断文件操作中的问题。

如上图所示,可以配合grep命令获取对应进程的相关行为。通过该命令,可以简化对文件流处理的追踪,直接从所执行的系统行为或系统调用中定位到具体的代码。

对于编程语言而言,它们提供了许多封装好的API函数。在Objective-C和Swift中,也有相当多的函数底层实现可以进行分析。这些封装好的API函数不仅简化了开发过程,还为开发者提供了强大的功能。通过对这些底层实现的分析,可以更好地理解其工作原理,并在必要时进行优化或调试。

此处编写一个使用moveItemAtPath:toPath:函数的示例代码,并为rename函数设置断点。通过这种方式,可以发现最终实现也会调用rename函数。

3.1 CVE-2020-9900 文件写入自定义目录

在macOS系统中,后台存在一个以root权限启动的进程,即CrashReporter。该进程负责收集系统各种崩溃报告和日志,并将其存放于/Library/Logs/DiagnosticReports~/Library/Logs/DiagnosticReports目录中。这两个目录的所属用户是_analyticsusers,而默认用户通常属于admin用户组,因此默认用户拥有读写上述两个目录的权限。

如上图所示,系统会根据报告生成的时间和进程名等字段,将崩溃报告存放于指定目录中。在这些目录中,存在一个名为Retired的子目录。报告的存放时间是有期限的,当报告超过这个期限时,另一个以root权限启动的进程SubmitDiagInfo会将旧报告移动到Retired目录中。由于SubmitDiagInfo同样是以root权限运行的进程,在没有沙箱等安全规则干扰的情况下,它在访问相关root权限的目录或文件时不会因为权限不足而受到限制。

fmyy@Macbook_M1 DiagnosticReports % ps -ef |grep SubmitDiagInfo
    0  1043     1   0 10上午 ??         0:03.10 /System/Library/CoreServices/SubmitDiagInfo server-init

而root权限的SubmitDiagInfo进程会将旧日志文件迁移到Retired目录中。对于DiagnosticReports目录,默认用户具有读写权限,因为该目录所属用户_analyticsusers属于admin用户组。因此,可以修改Retired目录的指向,使其指向一个特定的目录。当SubmitDiagInfo进程执行淘汰旧报告的操作时,会将伪造的日志文件迁移到其他默认用户无权写入但root权限可写的目录中。

而此处选择了/etc/periodic目录,可以存放任意扩展名结束的脚本,然后按照对应的存放目录,可以实现按周期执行。

fmyy@Macbook_M1 DiagnosticReports % ls -al /etc/periodic
total 0
drwxr-xr-x   3 root  wheel    96  9 11 11:40 .
drwxr-xr-x  83 root  wheel  2656 11 25 10:08 ..
drwxr-xr-x   2 root  wheel    64  9 11 11:40 daily
drwxr-xr-x   2 root  wheel    64  9 11 11:40 monthly
drwxr-xr-x   2 root  wheel    64  9 11 11:40 weekly

回退到旧版本系统时,可以通过加载的framework定位到,进程在迁移文件时并没有考虑周全,忽略了对Retired目录是否为符号链接以及其指向其他目录的检测。进程直接使用了相关函数进行文件移动。在Objective-C中,这些操作也仅仅是通过API函数来完成的。

3.2 CVE-2023-32407 同目录rename函数写入自定义目录

两个不同的目录,一个作为子目录的存在,对于rename函数确实很容易通过控制子目录的符号链接指向,从而限制任意文件拷贝,那么对于rename函数新旧地址处理都处于同一个目录呢?

在之前复现历史CVE的时候,笔者写了个简易的demo用来测试rename函数:

bool raceStart = false;
char OLD_PATH[] = "./ABC/FMYY";
char NEW_PATH[] = "./ABC/SuccessFMYY";

void pwn() {
		system("mkdir ./RESULT");
		system("rm -r ./MMM");
		symlink("./RESULT","./MMM");
    system("rm -r ./ABC");
    system("mkdir ./ABC");
    system("echo  RACE_RENAME!!! >./ABC/FMYY");

    
    std::thread thread_create_symlink([]() {
        while(true) {
            if(raceStart) {
                renameatx_np(AT_FDCWD,"./ABC",AT_FDCWD,"./MMM",RENAME_SWAP);
                usleep(10000);
            }
        }
    });
    
AGAIN:
    raceStart = true;
    usleep(100);
    rename(OLD_PATH,NEW_PATH);
    raceStart = false;
    system("rm -r ./ABC");
    system("rm -r ./MMM");
    symlink("./RESULT","./MMM");
    system("mkdir ./ABC");
    system("echo  RACE_RENAME!!! >./ABC/FMYY");

    goto AGAIN;
}

ABC目录和RESULT目录是一个常规文件夹,MMM是一个指向RESULT目录的符号链接。在代码进行rename之前,会先设置一个raceStart的符号,通知另一侧的多线程让ABC目录和MMM目录进行交换,交换一次就是ABC目录变为指向RESULT目录的符号链接,而MMM目录变为保存有FMYY文件的常规文件夹。因此renamerenameatx_np的竞争可以有三种情况:

  1. 若是在rename函数之前,目录属性发生交换,那么OLD_PATH获取不到vnode节点,rename函数失败从内核返回;
  2. 若是在rename函数之后,目录属性发生交换,那么行为已经发生,那么SuccessFMYY文件会存放在MMM文件夹;
  3. 若在rename函数执行过程中,目录属性发生了变化,使得ABC目录被切换为指向RESULT目录的符号链接,那么NEW_PATH此时获取的文件夹节点将变为RESULT目录。因此,rename操作会在RESULT目录下创建一个新的名为SuccessFMYY的节点。
利用

在Music.app启动之初,进程会加载Metal.framework框架,其中存在使用环境变量MTL_DUMP_PIPELINES_TO_JSON_FILE

它可以指向一个文件路径,在Metal.framework中通过getenv函数获取对应变量,并调用NSFileManager 类中的createFileAtPath方法。

NSString *filePath = getenv("XXX");
NSFileManager *fileManager = [NSFileManager defaultManager];
BOOL success = [fileManager createFileAtPath:filePath contents:nil attributes:nil];

所传入的filePath则是由MTL_DUMP_PIPELINES_TO_JSON_FILE变量控制。如果对应文件存在,则会导致覆盖原文件并创建对应文件名。

MTL_DUMP_PIPELINES_TO_JSON_FILE 指向/DIR/FILENAME,目录有效则会在/DIR/目录下,创建随机文件名的一个文件<.dat.nosyncXXXX.XXXXXX>,并写入数据到此文件中,最后再将此文件通过rename函数修改为当前目录下FILENAME文件。因为是同目录中,而rename可能会被认为无法如前一个漏洞一样,进行任意位置迁移,但是根据上述构造的demo可知,当rename函数在第二次取出目录节点的过程中,能够存在二次取目录节点的行为,可以说明这里rename函数本身之内能进行TOCTOU攻击。

所以rename函数在使用的时候,是不会检测相关的符号链接的,因此可以引出同一族的其他函数如下:

int renamex_np(const char *from, const char *to, unsigned int flags);
int renameatx_np(int fromfd, const char *from, int tofd, const char *to, unsigned int flags);
     RENAME_NOFOLLOW_ANY
          If any symbolic links are encountered during pathname resolution, an error is returned.

renamex_np函数和renameatx_np函数的时候,可以通过添加flag位RENAME_NOFOLLOW_ANY进行检测,它会阻断路径中所有的符号链接。

前面两个历史CVE是对于rename函数而言,文件迁移的行为中,本地攻击者可以通过竞争攻击,导致最终写入的路径发生变化,限制之一是需要控制对目录的删除创建权限,那么对于open函数呢?

open函数在打开一个文件的时候,可以直接访问软链接指向的文件,最简单的方式就是控制传入给open函数的文件路径,经由特权进程,则可以向特定的文件中写入特定的内容。

3.3 CVE-2020-3830 任意文件覆盖

当用户在 macOS 上安装app时,系统都会将其记录到名为InstallHistory.plist 的文件中,该文件位于/Library/Receipts。例如当从AppStore安装QQ音乐应用的时候,安装完成,则会将安装的应用相关信息记录在此文件中。

根据fs_usage可以监控到是由installd进程进行写入的:

14:55:03  open              /Library/Receipts/InstallHistory.plist      0.000102   installd    
14:55:03    RdData[AT1]     /Library/Receipts/InstallHistory.plist      0.000198 W installd

通过查看对应文件及当前文件夹的属性可以发现,目录属于admin用户组,同时默认用户是隶属于admin用户组其中一员。

fmyy@Macbook_M1 raceRENAME % ls -al /Library/Receipts
total 240
drwxrwxr-x   4 root        admin     128 10 28 23:46 .
drwxr-xr-x  69 root        wheel    2208 10 28 23:45 ..
-rw-rw-r--   1 root        admin  122875 10 28 23:46 InstallHistory.plist
drwxr-xr-x   2 _installer  admin      64 10 22 15:49 db

而默认用户可以在此目录进行读写修改,那么系统记录所使用的InstallHistory.plist文件,若是被用户修改为一个指向其他文件的符号链接,那么在下一次安装应用的时候,安装完成,会将安装记录追加到符号链接所指向的任意文件中,但是由于内容不可控,所以只能作为任意文件覆盖为不可控内容。

最终修复则是将相关写入行为在进程的临时文件夹中操作,而对应文件夹则是会被Sandbox所保护,无法在其中进行修改文件的行为,写入完成则会通过renameat函数拷贝到指定目录中。

3.4 CVE-2023-32428 bacmalloc

如此简单的符号链接,开发者怎么能想不到呢?所以在使用open函数的时候,自然考虑到了一个标志位字段,而其中一个字段则是名为 O_NOFOLLOW,其功能如下。它不允许追随文件是符号链接指向其他文件,但是,这么做就能遏制住符号链接的使用吗?

#define O_NOFOLLOW      0x00000100      /* don't follow symlinks */

若是设置了该字段,如CVE-2020-3830这般则不会被允许打开对应文件并访问软链接所指向的路径所对应的文件,但是O_NOFOLLOW字段的出现存在一个忽略的地点,即O_NOFOLLOW字段并不会去检测整个路径中的父级目录是否使用符号链接指向特定的目录,若是能控制访问过程中的目录,则O_NOFOLLOW字段的功能则会失效,从而引发进一步的漏洞产生。

CVE-2023-32428则是由于忽视这点而导致的,macOS系统中存在一个系统库MallocStackLogging.framework,它在被加载之后,会去检测环境变量的存在,其中存在一个特殊的环境变量MallocStackLoggingDirectory,它将会在环境变量所对应的目录中,写入一个随机文件,用来记录相关数据。笔者所述:

1. 	the destination directory is checked with the access() syscall first, and if that returns -1, no operation will be done

2.  open() will be used to create a file

	2.1 but it won't overwrite files (O_CREAT | O_EXCL)

	2.2 won't follow symlinks (O_NOFOLLOW)

	2.3 permission bits are correctly set to 0o700 (rwx------), so we can't play tricks with umask

首先会使用一次access函数来检测目标目录,再使用open函数进行文件的创建并返回一个文件描述符,open函数虽然使用了O_CREAT字段创建文件,但是同时使用了O_EXCL文件,因此无法覆盖已经存在的文件;第二点则是使用了O_NOFOLLOW字段。由上可知,在能控制路径的情况下,O_NOFOLLOW字段所存在的理由已经告破,因为可以通过切换父级目录来进行绕过;第三点则是open函数的第三个参数mode通常会被忽略,但是这里也是按照安全的0o700来进行创建的。

最开始笔者想要尝试通过预测文件名来进行任意目录写文件行为,而当传入的路径足够长的时候,最终文件名会发生截断。如下所示,此处可以拥有一个向任意目录写入名为s文件的权限。

$ MallocStackLoggingDirectory="/tmp/$(python3 -c "print('/'*1017)")" MallocStackLogging=1 id
...
id(77705) MallocStackLogging: stack logs being written to /tmp////[TRUNCATED]///s
...

但是因为没有合适的目录进行利用,故搁置。

最终利用

第二次尝试利用的时候,作者发现open函数不存在O_CLOEXEC属性,而O_CLOEXEC属性的作用如下:

  • 定义

    O_CLOEXEC 是一个常量,通常在 <fcntl.h> 头文件中定义。它可以与其他打开标志(如 O_RDONLYO_WRONLY 等)一起使用。

  • 使用场景:

    当打开一个文件并希望在调用exec函数(如 execl, execv 等)时,不希望新进程继承该文件描述符时,可以使用 O_CLOEXEC。这在处理临时文件或需要限制访问的文件时特别有用。

那么此处选择了一个suid属性的系统root用户可执行文件crontab,因为带有suid属性的可执行文件可以以对应文件所属用户的权限进行执行:

fmyy@MacBook_M3 Debug % ls -al /usr/bin/crontab
-rwsr-xr-x  1 root  wheel  171168 11 17 09:41 /usr/bin/crontab

crontab是系统中用于设置周期性被执行指令的命令,通过crontab -e命令可以执行随之所给定的脚本。当crontab执行所给定的文件脚本的时候,则会将文件描述符暴露给攻击者,通过所暴露的文件描述符则可向特定的文件写入数据。

To exploit this vulnerability all we have to do is:
- set $EDITOR to our script
- execute MallocStackLogging=1 MallocStackLoggingDirectory=$PWD/dir1 crontab -e
- in another process, race the file open by switching dir1 with a symlink poiting to /etc/sudoers.d
- in our script we detect if we were successful (we have an open file under /etc/sudoers.d)
   - we wait a bit
   - truncate the file
   - write ALL ALL=(ALL) NOPASSWD:ALL to it
- we sudo to root without a password :)

因此最终可以引出新的标志位O_NOFOLLOW_ANY,和renameatx_np函数的RENAME_NOFOLLOW_ANY字段类似,它将限制整个路径存在符号链接的可能性,能最大限制的杜绝由符号链接导致操作文件行为的劫持。

#define O_NOFOLLOW_ANY  0x20000000      /* no symlinks allowed in path */

四、总结

本文通过分析四个与文件操作相关的API函数所导致的漏洞,发现开发过程中对符号链接的忽视,可以逐渐演变成各种各样的安全问题,包括隐私绕过和系统提权。

在代码设计过程中,如果对O_NOFOLLOW等字段的使用不当或考虑不周全,仍然可能被绕过。例如,如果没有正确使用O_NOFOLLOW字段,或者没有禁止O_CLOEXEC字段,可能导致原本较小的漏洞扩大影响,从任意目录写文件演变成稳定的本地权限提升。无论是Linux、macOS还是Windows,近年来都存在多个由于符号链接导致的严重漏洞。这些逻辑漏洞的稳定性和可利用性都非常高,是一个不可忽视的安全方面。

五、参考链接

  1. CVE-2020-9900 & CVE-2021-1786 - Abusing macOS Crash Reporter
  2. badmalloc (CVE-2023-32428) - a macOS LPE
  3. lateralus (CVE-2023-32407) - a macOS TCC bypass