Symlink and Hardlink Attacks: Lying to Root About Where Files Live
A privileged process writes a file. It runs as root, it writes to a directory it is responsible for, and it never imagines that the path it is writing to might not point where it thinks. That gap, between a path and the actual file it resolves to, is the entire premise of symlink and hardlink attacks. Plant a link where the privileged process expects a normal file, and you redirect its write to somewhere you could never write yourself. The root process does the writing; you choose the destination.
This post covers the macOS filesystem permission model that makes these attacks possible (and the flags that frustrate them), the methodology for finding the bugs, and three real CVEs: a DiagnosticMessages file overwrite (CVE-2020-3855), an Adobe Reader installer escalation (CVE-2020-3762), and a manpages privilege escalation (CVE-2019-8802). After several posts about identity, this one is about a humbler lie: not impersonating an app, just confusing a process about which file a name refers to.
The Permission Model, and What Stands in Your Way
macOS is POSIX at the filesystem level: every file has owner, group, and other permission bits. But for link attacks the interesting controls are the ones layered on top, because they are what decides whether a given attack works.
File flags (chflags) sit above the permission bits. The one that matters most is uchg (user
immutable) / schg (system immutable): a file with uchg set, owned by root, cannot be changed by
anyone, regardless of permissions. Flags can stop an overwrite that the mode bits would otherwise
allow.
The sticky bit on a directory changes deletion rules. Normally, write access to a directory lets
you rename or delete any file in it. With the sticky bit set, think /tmp, only the file’s
owner (or root) can rename or delete it. That single bit defeats the most common link attack:
“delete the file root will write and replace it with my link.” If the directory is sticky and the
file is root-owned, you cannot remove it to swap in your link.
Access Control Lists add finer-grained allow/deny entries beyond owner/group/other. They are not used heavily by default, but they appear, a home directory may carry an ACE that denies deletion to everyone, and an ACL can block a step in an exploit that the POSIX bits would have permitted.
So before any link attack, the question is the interaction of these: is the target directory writable by me, is it sticky, is the target file immutable, are there ACLs in the way? The vulnerability is always a directory where a privileged process writes that an unprivileged user can also manipulate, without a sticky bit or flag closing the gap.
Symlinks and hardlinks fail differently, and the difference decides which CVEs are exploitable. A process can defend against symlinks (refuse to follow them, or recreate the file). A hardlink is not a pointer to a file (it is the file, a second name for the same inode) so “don’t follow the link” is meaningless against it. When a target directory is sticky, symlinks often won’t work and hardlinks become the move.
Finding the Bugs
The methodology is monitoring plus reasoning. You want to catch a privileged process writing to a location you can influence:
- Static analysis: read the privileged binary (or installer, or daemon) and find where it constructs paths and writes files, looking for writes into directories that are not fully locked down.
- Dynamic analysis: watch the live system. A useful approach is a script that walks directories
checking for ones writable by the user’s groups but owned by root:
os.stat(directory).st_mode & S_IWGRPagainst admin-group ownership, and monitors for files written as root into locations the attacker can control. DTrace and filesystem monitoring from the tooling post do the same job.
The exploitable condition, stated plainly: a root process writes a file into a directory where you have write (or delete) permission, the directory is not sticky in a way that stops you, and the target file is not immutable. Then you pre-place a link.
The general exploit, and its three failure modes worth knowing up front:
- Create a symlink or hardlink, with the same name the privileged process will write, pointing at a protected location.
- Let the privileged process perform its write: which now lands on your chosen target.
But: the process might run as root yet be sandboxed, so it still can’t write everywhere. It might not follow symlinks, overwriting your link instead (use a hardlink). And even after a successful redirect, the resulting file is owned by root with content you only partly control, so whether you get full privilege escalation depends on whether you can make that content meaningful.
CVE-2020-3855: DiagnosticMessages Overwrite
The first CVE is a clean, limited demonstration. /private/var/log/DiagnosticMessages is written by
a root process (the logging system) but the directory is group-writable by admin:
# filename: diagmsg-perms.txt
drwxrwx--- 7 root admin 224 DiagnosticMessages
-rw-r--r-- 1 root admin ... 2020.10.28.asl # files owned by root, written by a root process
Because the attacker user is in admin, it can manipulate the directory’s contents even without
knowing root’s password. Symlinks don’t work here, the relevant behavior recreates/overwrites, so
the attack uses a hardlink:
# filename: diagmsg-exploit.txt
# 1. create a root-only target to overwrite
% sudo touch /Library/a.asl # (stand-in for a protected file)
# 2. remove the log file root will write, and hardlink its name to the target
% cd /private/var/log/DiagnosticMessages
% rm 2020.10.28.asl
% ln /Library/a.asl 2020.10.28.asl # same inode now has both names
# 3. reboot / wait for the logger to write; root's write lands on /Library/a.asl
After the logger writes, both names show the same inode and the same content: the root process wrote through your hardlink into the target. The limitation is honest: you can only get an empty ASL-format log file overwritten, and you do not fully control the bytes, so this alone is not a root shell. It is the primitive, “make root write here”, and the rest of this post is about turning a controlled-write primitive into something more.
That “something more” is content control. The technique reverses the logging path to a private
msgtracer API, reachable through the undocumented CalMessageTracer class in CalendarFoundation.
class-dump recovers its interface, and by driving CalMessageTracer you gain influence over what
actually gets logged: converting a blind overwrite into a write whose content you partly steer. The
lesson: a file-overwrite primitive is only as good as your control over the resulting content, and
recovering that control is its own reverse-engineering exercise.
CVE-2020-3762: Adobe Reader Installer
The second CVE moves from a logging daemon to an installer, which is fertile ground for link attacks because installers do exactly the dangerous thing: run as root and write many files into predictable, often loosely-permissioned locations. Adobe Reader’s macOS installer performed a privileged file operation against a path an unprivileged user could pre-stage with a link, redirecting a root-owned write to escalate privileges. The shape is identical to DiagnosticMessages (root writes, attacker controls the path via a link) but the impact is higher because an installer’s writes can land on files that execute or that grant access, turning the controlled write directly into code execution as root.
The takeaway is a target-selection heuristic: audit installers and updaters first. They are privileged, they touch many paths, they run on a schedule or on user action, and they are written by application teams rather than OS engineers. The XPC post made the same point about helper tools; link attacks make it about anything that writes files as root.
CVE-2019-8802: Manpages Privilege Escalation
The third CVE rounds out the set by targeting the manpage tooling, where a root-run process handling the manual-page database could be steered, via a link, into writing where the attacker wanted. Again the same template: a privileged file operation, a path the user can influence, a link planted in between. Three CVEs across logging, installation, and manpages, and they are one bug class viewed from three angles: which is precisely why studying them together teaches the pattern rather than three unrelated facts.
Why This Matters
Symlink and hardlink attacks are old, cross-platform, and stubbornly alive on macOS because the
vulnerable pattern keeps recurring: privileged code writes to a path without proving the path
resolves to the file it intends. The defenses exist (sticky bits, immutable flags, ACLs, refusing
to follow symlinks, opening files with O_NOFOLLOW, validating the resolved target) but they have
to be applied at every privileged write, and they routinely are not, especially in installers and
helper daemons written outside Apple.
For an attacker the method is durable: find a directory where root writes and you can place files, check that no sticky bit or flag stops you, plant a hardlink (so “don’t follow links” doesn’t save the target), and then work on controlling the content of the redirected write. The first two steps give you a write-where primitive; the third is what turns it into root.
These three CVEs only overwrote files. The series’ next and hardest target is the kernel itself: getting unsigned code to load as a kernel extension, where a race condition between staging and verification turns the same TOCTOU thinking into ring-0 code execution.
References
- MITRE. CVE-2020-3855, CVE-2020-3762, CVE-2019-8802.
- Oakley, H. Access Control Lists on macOS. The Eclectic Light Company.
- Apple. chflags(2), sticky(7), chmod(1) ACL syntax. macOS man pages.