Skip to content
all posts
Writeup 2 min read

User site-packages load inside the root installer, a CVE-2024-27822 variant

Apple taught bash and zsh to skip user config when it escalates during a .pkg install. Python was never taught. An attacker drops one module in site-packages and it runs as root on the next package the user installs.


A .pkg install runs its scripts as root. If a script is written in Python, /usr/bin/python3 imports site on startup, and site adds the user’s site-packages directory to the import path. Any module the interpreter finds there runs with root’s privileges.

The user does not need to be involved. They install a package from a vendor they trust, and a file the attacker wrote earlier runs as root inside Apple’s installer.

Apple fixed it. This is the shape of it, and how to check any machine you own.

Reproduce

Requirements: a Mac, a shell, and no root. The last step needs sudo on your own machine.

Step 1: ask the interpreter where it looks.

/usr/bin/python3 -c 'import site, sys; print(site.ENABLE_USER_SITE); print([p for p in sys.path if "Library/Python" in p])'
True
['/Users/andrei/Library/Python/3.9/lib/python/site-packages']

Step 2: ask it the same question with the variable Apple’s installer sets when it escalates.

APPLE_PKGKIT_ESCALATING_ROOT=1 /usr/bin/python3 -c 'import site; print(site.ENABLE_USER_SITE)'
True

Bash and zsh check that variable and skip the user’s own config when it is set. That is the fix Apple shipped for CVE-2024-27822. Python reads the variable and does nothing with it.

Step 3: put a file where the interpreter will find it, and make a package whose postinstall is a Python script. The full harness is a .pkg with a postinstall shebang of #!/usr/bin/python3; installing it prints:

[2026-01-02 23:27:59] Python loaded sitecustomize.py
[2026-01-02 23:27:59]   UID: 0, EUID: 0
[2026-01-02 23:27:59]   Script: /tmp/PKInstallSandbox.Ti5EJ8/Scripts/com.test.pythonpoc.z1PkUW/postinstall
[2026-01-02 23:27:59]   APPLE_PKGKIT_ESCALATING_ROOT: 1
[2026-01-02 23:27:59] *** RUNNING AS ROOT - EXPLOITATION SUCCESSFUL ***

The module that printed that ran as uid 0, from a directory the attacker controls, in an install the user started.

Why it worked

Three things line up, and each one is its own gap.

Python has no escalation check. The mitigation for the shell variant lives in the shell startup files. Python’s startup path has an equivalent hook — sitecustomize — and no guard.

The installer inherits root. system_installd runs the script as root with SIP and TCC entitlements, so the module inherits them. The script is not the payload; the import is.

One write, every install. The module stays in place. Every later .pkg that calls Python re-triggers it, with no further action from the attacker.

What I proved, and what I did not

Proved: the interpreter still loads user site-packages with APPLE_PKGKIT_ESCALATING_ROOT=1 set, and a module placed there executes inside a package install running as uid 0.

Not proved: this particular module is the exact one Apple’s CVE covers. Apple does not disclose the root cause of a fixed issue, so the mechanism here and the shipped fix are consistent, not identical. The test machine was macOS 26.2 (25C56); the fix shipped in Tahoe 26.4.

The fix

Apple’s, already shipped: the interpreter no longer honours the user’s site directory in an escalated install. Tahoe 26.4, Sequoia 15.7.7, Sonoma 14.8.7.

The interpreter has supported the fix all along. Either of these disables it for any script:

/usr/bin/python3 -s -c 'import site; print(site.ENABLE_USER_SITE)'      # False
PYTHONNOUSERSITE=1 /usr/bin/python3 -c 'import site; print(site.ENABLE_USER_SITE)'   # False

If you ship a .pkg that runs Python, put -s in the shebang. Do not rely on the installer deciding for you.

The rule

Privileged code should not read configuration the unprivileged user can write. When a mitigation lands in one interpreter, the same question has to be asked of every other interpreter the privileged path invokes — and the answer has to be a test you run, not an assumption.

verify_python_installer_usersite.sh in the research notes replays the read-only checks above.

Related

Self-serve

Hugin is built

The intercepting proxy and scanner I build and use daily — one Rust binary, local-first, no account. Community is free; Pro is €10/month when you need the offensive tier.

Get Hugin