macOS's System Integrity Protection sanitizes your environment

tags:

macOS added System Integrity Protection in El Capitan. It’s supposed to limit what root users can do and prevent changes to system files. Noble goals, especially for non-developers.

But its more than. Some programs sanitize the environment before they start child processes so I can’t substitute my own, potentially malicious libraries. I’m being generous when I say that this is lightly documented. Specifically, environment variables starting with DYLD_ and LD_ are unset for child process started by system programs. This works in the Apple ecosystem, but some third-party tools still rely on it.

That macOS would do this makes sense for most people. These environment variables were supposed to be about debugging so you could compile a library and try it with a program. As with many things, how people used it was different. As a developer, I’m doing things virtually no one else in the world is doing. I don’t want to use these variables, but other things I want to use wants to use these variables.

Debugging hell

Imagine trying to debug something like this in a bizarro world in which I swear I’ve set the variable, I’ve stared at it for minutes to ensure I’ve spelled it correctly instead of DLYD_LIRARY_PATH, and I can see it in small test programs. I swear it’s set up, but when I go back to the big situation, it’s gone.

I discovered this because I was testing some Postgres stuff and setting different values on the command line:

$ env PGSSLMODE=require ...

My Perl program then couldn’t find /Library/PostgreSQL/12/lib/libssl.1.1.dylib and I’d get an error that says it can’t find the library:

Library not loaded: libssl.1.1.dylib

Googling did not help that much. Most answers were just cargo-culting advice about Homebrew that is essentially “have you tried reinstalling it?”

As an aside, I don’t mind that people use convenience tools such as brew. However, you should understand what it does and how it does it if you want to be a developer. You can see the trail of destruction and wasted time from people who’ve neglected to learn their tools—they can’t even diagnose the problem.

DYLD_LIBRARY_PATH

I can set the DYLD_LIBRARY_PATH environment variable to tell processes where it can find dynamic libraries, and I can see that I’ve set it:

$ export DYLD_LIBRARY_PATH=/Library/PostgreSQL/12/lib
$ echo $DYLD_LIBRARY_PATH
/Library/PostgreSQL/12/lib

With a perl that I’ve compiled and installed myself (not the macOS system Perl), I can see the value from a Perl program, but from the system perl I can’t:

$ which perl
/Users/brian/bin/perl
$ perl -le 'print $ENV{DYLD_LIBRARY_PATH}'
/Library/PostgreSQL/12/lib
$ /usr/bin/perl -le 'print $ENV{DYLD_LIBRARY_PATH}'

$

But, if I run the same Perl one-liner under env, I can’t see it:

$ env which perl
/Users/brian/bin/perl
$ env perl -le 'print $ENV{DYLD_LIBRARY_PATH}'

$

This isn’t a Perl thing. Here’s the same thing in Ruby, which I installed myself:

$ which ruby
/usr/local/bin/ruby
$ ruby -e 'puts ENV["DYLD_LIBRARY_PATH"]'
/Library/PostgreSQL/12/lib
$ env ruby -e 'puts ENV["DYLD_LIBRARY_PATH"]'

$

Even worse, just running env means I won’t see all of the environment variables. The variable is set and I can see it with echo, but env doesn’t even know it exists:

$ env | grep DYLD
$

And here’s a small Makefile to show the value of DYLD_LIBRARY_PATH:

all:
	@ echo "DYLD_LIBRARY_PATH=" $(DYLD_LIBRARY_PATH)

It also sanitizes the environment when I use the XTools make (and I’d rather not muddy the waters with a different set of tools):

$ which make
/usr/bin/make
$ echo $DYLD_LIBRARY_PATH
/Library/PostgreSQL/12/lib
$ make
DYLD_LIBRARY_PATH=

What can I do?

As the nuclear option, I can turn off SIP. I have to boot into Recovery Mode and disable SIP from the terminal, then reboot into normal mode:

$ csrutil disable

I don’t really want to do that though. I’d rather leave my base system as close to pristine as I can. The more I diverge from the normal case, the less I develop for the normal case. Something works accidentally for me because I have a special. more omniscient system.

I can change my Makefile to set a default (assign with ?=) with a safe environment variable name and re-export it. Without the export, the echo sees the Makefile variable, but perl would not see an environment variable:

export DYLD_LIBRARY_PATH ?= $(MY_DYLD_LIBRARY_PATH)

all:
	echo "DYLD_LIBRARY_PATH=" $(DYLD_LIBRARY_PATH)
	perl -le 'print $$ENV{DYLD_LIBRARY_PATH}'

This requires me to set the extra environment variable, which is easy enough in a startup file. The hard part is adjusting foreign code to use this. This is where I think most developers would stop because it’s achievable and we’re used to working around obstacles when we can’t get through them.

However, I didn’t give up. There must be a better way. If I’m not supposed to use DYLD_LIBRARY_PATH, how does Apple expect me to do it?

After I compiled DBD::Pg, I looked at the .bundle file it created. otool can show you which libraries it wants:

$ otool -L ./blib/arch/auto/DBD/Pg/Pg.bundle
./blib/arch/auto/DBD/Pg/Pg.bundle:
	libssl.1.1.dylib (compatibility version 1.1.0, current version 1.1.0)
	libcrypto.1.1.dylib (compatibility version 1.1.0, current version 1.1.0)
	libpq.5.dylib (compatibility version 5.0.0, current version 5.12.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1281.100.1)

Three of those paths are relative, which is why it needs something like DYLD_LIBRARY_PATH to find them. The other one is an absolute paths. Huh. If I update those to absolute paths, I don’t need to search through directories for them. The install_name_change tool (virtually completely undocumented) does this.

There are two ways to go here based on where this library is. If I can’t tell the root directory for the library, I can use the RPATH infrastructure. However, in this case, I know that the directory is going to be /Library/PostgreSQL/12/lib, so I can use absolute paths for these libraries.

The install_name_change lets me update the library paths for either RPATH or absolute paths (Fun with rpath, otool, and install_name_tool is a nice read):

$ install_name_tool -change libssl.1.1.dylib /Library/PostgreSQL/12/lib/libssl.1.1.dylib ./blib/arch/auto/DBD/Pg/Pg.bundle
$ install_name_tool -change libcrypto.1.1.dylib /Library/PostgreSQL/12/lib/libcrypto.1.1.dylib ./blib/arch/auto/DBD/Pg/Pg.bundle
$ install_name_tool -change libpq.5.dylib /Library/PostgreSQL/12/lib/libpq.5.dylib ./blib/arch/auto/DBD/Pg/Pg.bundle

I check with otool again to make sure it took:

$ otool -L ./blib/arch/auto/DBD/Pg/Pg.bundle
./blib/arch/auto/DBD/Pg/Pg.bundle:
	/Library/PostgreSQL/12/lib/libssl.1.1.dylib (compatibility version 1.1.0, current version 1.1.0)
	/Library/PostgreSQL/12/lib/libcrypto.1.1.dylib (compatibility version 1.1.0, current version 1.1.0)
	/Library/PostgreSQL/12/lib/libpq.5.dylib (compatibility version 5.0.0, current version 5.12.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1281.100.1)

And that works. I’ve filed an issue on DBD::Pg, but I haven’t worked on fixing the module installer.

A list of ignored variables:

SIP strips out any environment variables starting with DYLD_ or LD_, but here are the ones for the search engines:

  • DYLD_LIBRARY_PATH
  • DYLD_FALLBACK_LIBRARY_PATH
  • LD_LIBRARY_PATH
  • DYLD_INSERT_LIBRARIES