Repository: Gallopsled/pwntools

Issue:
ROP class breaks if cache_dir is None
Even after the cache logic improvements recently merged in #1784, there are still some codepaths in `ContextType.cache_dir()` that return `None`. As alluded to in that PR, if this is triggered, it makes the `ROP` class completely unusable.

It's fundamentally impossible to guarantee that pwntools will always be able to find a writable directory somewhere, and `cache_dir`'s docstring clearly states that it may return `None`, so I think the incorrect assumption to the contrary in the ROP class is to blame here.

Here's an easily reproducible example. Although this particular way of getting the `None` cache dir is very contrived, I *have* originally encountered this bug on a real (albeit rather restricted) system :)

```sh
$ echo -e '#include <stdio.h>\nint main() { printf("Hello world"); return 0; }' > hello_world.c
$ gcc -o hello_world hello_world.c
$ export DIRNAME=.pwntools-cache-$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])')
$ mkdir -p /tmp/test/$DIRNAME
$ chmod -w /tmp/test/$DIRNAME
$ XDG_CACHE_HOME=/tmp/test python3 -c 'import pwn; pwn.rop.ROP("./hello_world")'
[*] '/.../hello_world'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/.../pwnlib/rop/rop.py", line 598, in __init__
    self.__load()
  File "/.../pwnlib/rop/rop.py", line 1281, in __load
    cache = self.__cache_load(elf)
  File "/.../pwnlib/rop/rop.py", line 1212, in __cache_load
    filename = self.__get_cachefile_name(elf)
  File "/.../pwnlib/rop/rop.py", line 1192, in __get_cachefile_name
    cachedir = os.path.join(context.cache_dir, 'rop-cache')
  File "/usr/lib/python3.8/posixpath.py", line 76, in join
    a = os.fspath(a)
TypeError: expected str, bytes or os.PathLike object, not NoneType
```

I haven't looked at the ROP class in detail to see what it's using the cache for, but if it truly can't function correctly without a cache directory, it should really at least have a proper error message for this instead of the confusing generic one.

Workaround for anyone else who runs into this: when using pwntools, invoke `python3` with `XDG_CACHE_HOME` set to some directory you have write access to. You can do this in your Python script itself by assigning to `os.environ['XDG_CACHE_HOME']` somewhere near the top.


Discussion:

Resolving patch:
diff --git a/pwnlib/rop/rop.py b/pwnlib/rop/rop.py
--- a/pwnlib/rop/rop.py
+++ b/pwnlib/rop/rop.py
@@ -1189,6 +1189,9 @@ def __flat_at__(self, address):
 
     def __get_cachefile_name(self, files):
         """Given an ELF or list of ELF objects, return a cache file for the set of files"""
+        if context.cache_dir is None:
+            return None
+
         cachedir = os.path.join(context.cache_dir, 'rop-cache')
         if not os.path.exists(cachedir):
             os.mkdir(cachedir)
@@ -1205,12 +1208,14 @@ def __get_cachefile_name(self, files):
     @staticmethod
     def clear_cache():
         """Clears the ROP gadget cache"""
+        if context.cache_dir is None:
+            return
         cachedir = os.path.join(context.cache_dir, 'rop-cache')
         shutil.rmtree(cachedir)
 
     def __cache_load(self, elf):
         filename = self.__get_cachefile_name(elf)
-        if not os.path.exists(filename):
+        if filename is None or not os.path.exists(filename):
             return None
         gadgets = eval(open(filename).read())
         gadgets = {k - elf.load_addr + elf.address:v for k, v in gadgets.items()}
@@ -1218,8 +1223,11 @@ def __cache_load(self, elf):
         return gadgets
 
     def __cache_save(self, elf, data):
+        filename = self.__get_cachefile_name(elf)
+        if filename is None:
+            return
         data = {k + elf.load_addr - elf.address:v for k, v in data.items()}
-        open(self.__get_cachefile_name(elf), 'w+').write(repr(data))
+        open(filename, 'w+').write(repr(data))
 
     def __load(self):
         """Load all ROP gadgets for the selected ELF files"""


Test patch: