Making executable PDFs for fun and profit

But mostly for fun
2024-04-26

I recently had the idea to embed a little game or program inside my résumé, as an easter egg for the curious (and technically-minded) recruiter.
I had previously heard of “polyglots” — files that may be interpreted as multiple different file formats — and decided to try my hand at making one, combining a PDF file and a Linux executable into one.

This was not a new idea, and had been done before. In fact, I based my work on the examples on the Polydet/polyglot-database Github repo. But the explanation in the PDF itself is somewhat confusing, so here is my attempt at describing how such a thing is possible, if you want to try it for yourself.

Structure of a Linux executable

Structure of an ELF file

The formal name of the file format used for Linux executable files is ELF (Executable and Linkable Format). This format is also used for other files containing code, notably object files (.o) and shared libraries (.so).

An ELF file consists of various “blocks” (the boxes in the diagram), which can reference other blocks by specifying their position within the file (the arrows). This forms a tree structure, which is accessed through its root: the ELF header block at the very beginning of the file.

The ELF header begins with a magic number: the byte 0x7f, followed by ELF in ASCII. This sequences marks the file as being in the ELF format. The header also contains various pieces of information about the executable, such as what CPU architecture it is meant for.

The header references the “program header table” and the “section header table”, which, together, specify “sections” of the executable to be loaded in memory when the program is run. Said sections contain the actual code and data of the program, and are typically inserted between the two tables, as in the diagram.

This structure means that as long as we don’t touch the ELF header and keep the references valid, we can insert arbitrary data between blocks or at the end of the file. On top of that, we can simply add sections containing our data, which will be ignored by the program when it runs. We will see how to do this later.

Structure of a PDF file

Structure of a PDF file

Unlike ELF which is a mostly binary format, PDF (the Portable Document Format) is mostly text-based. You can open a PDF file in a simple text editor and get (mostly) legible output: try it!

A PDF file starts with the line %PDF-1.7, where 1.7 is the version of the PDF format being used.

This is followed by a list of various “objects”, each with a numeric identifier. An object can be a boolean, a number, a string, a symbol, an array of objects, or (most commonly) a dictionary mapping symbols to objects. A dictionary can be followed by a “stream”: an arbitrary sequence of bytes with a specified length. (This is the part that makes the PDF format not entirely text-based.) These objects encode all of the data that makes up the PDF document.

After the list of objects come four final blocks:

The main trick

A PDF file could be read in order, starting from the beginning and building up the set of objects. But typically, it is read from the end: working backwards from the footer, finding the xref table, then reading all the objects by following the links from there.

For this reason, PDF readers tend to be more strict about the placement of the final four blocks than the header. In fact, so many “technically invalid” PDF files have been made over the years that seemingly all PDF readers allow the PDF header to be anywhere within the first 1024 bytes of the file. Once the reader has checked the header exists in that range, it goes to the end of the file to start reading.

And you know what typically fits within 1024 bytes? An ELF header + program header table.

Structure of our polyglot

Structure of our polyglot PDF-ELF file

This “looseness” of the PDF format is all we need to make an PDF + ELF polyglot (or PDELF, as I like to call it), by interleaving and embedding the various blocks from both file formats in the right way.

We start with the ELF header: this has to be at the very beginning of the file for the polyglot to be recognized as a valid ELF file.
After that come the program headers. This isn’t a hard constraint, but programs that generate ELF files seem to always do it this way.

Then, we insert a custom section, as early as possible, which contains most of our PDF: the PDF header, and the list of objects. As long as the ELF headers and program headers take less than about 1000 bytes (usually true), the PDF header will be within the first 1024 bytes of the file, and the polyglot will be recognized as a valid PDF file.

To make sure the PDF file is valid when parsing it from beginning to end, we embed the rest of the ELF file inside a custom stream object, with a very high identifier (to avoid collisions with existing objects). This keeps the syntax valid, but since the object isn't referenced anywhere, it won't have any effect on the PDF.

Finally, after the end of the ELF data, we close the stream and our custom object, then add the remaining PDF blocks: the xref table, the trailer, the startxref tag, and the footer.

Ta-da!

But how do we actually make it?

Now we have to figure out how to create such an interleaved monstrosity. The method I came up with is the following:

Final words

Phew.

As you can see, it's quite the process. I automated it with a Python script, but it's not reliable enough for me to want to release it to the public. Notably, the behavior of the linker is quite fiddly. The address 0x103f0 worked in my tests, but it may need to be adjusted for different linkers or linker scripts. On top of that, this process only works if you can compile the program from scratch with the compiler options you want, which may not be your case.

Another possibility, which I think would be more reliable, would be to modify an existing ELF binary directly to insert the PDF data. This would involve inserting the PDF body (+ custom object header) right after the ELF header, then adjusting all the references in the ELF structure to accomodate the new location of all the blocks. The downside is that this requires a much more in-depth understanding of the ELF format. Notably, some sections have alignment requirements, so simply shifting everything by N bytes, with N the size of the PDF body, will likely not work. In any case, I leave implementing this method as an exercise for the reader.

Finally, it turns out that PDF readers are very liberal in what they accept, so you may not need such a complex process. In my tests, you can choose to include the entire PDF directly as an early section of the binary, and PDF readers will usually open it anyway. I believe that after not finding the final sections where they expect them to be, they read the file from the start up until it stops making sense. They might show a warning however. In my use case of an easter egg inside a résumé, this was a problem: I wouldn't want a recruiter to be put off by a warning when opening my résumé.

That's the end of this explainer. Thank you for reading!