Erlang and BEAM Bytecode Binary Golfing

I recently had the unfortunate experience of having to reverse engineer a large Erlang application. So, when I saw haxrob's entries [1] to BGGP5[2], I decided to write some Erlang of my own. This blog post will start out with an improvement to haxrob's submission which saves 24 bytes, and then it will move on to BEAM bytecode golfing.

Entry 1: Erlang

haxrob's original submission (129 bytes)

My submission (104 bytes)

Both versions start out the same. The -module attribute is required for all Erlang modules, and the -export attribute is what allows us to actually call the function. Both modules also define a single-letter function name, and start by calling ssl:start() and inets:start(). Both modules also use httpc:request to download the web page. haxrob's submission extracts the body of the request and display it using io:putchars, and while this is a short way to generate a clean output, it costs quite a few bytes. As it turns out, if you try to call an undefined external function, the error message will nicely format the arguments. That means displaying the message is as simple as wrapping the httpc:request() call in an invalid call to a nonexistent function, such as q:q/1 in my submission. Finally, httpc follows redirects so I was able to save a byte by replacing "https" with "http" in the url.

Entry 2: Compiled .beam Bytecode File

The file produced by erlc is a BEAM bytecode file. The format of this file is based on "EA IFF 85", which is a generic TLV container format consisting of multiple sections or "chunks". Each chunk is identified by a 32-bit tag and can be up to UINT32_MAX bytes long. There is an additional "FOR1"/"FORM" header at the beginning of the file, which contains the length of the entire file.

Technique 1: Stripping sections

The .beam file produced by erlc contains a signifcant amount of unneeded information used for debugging and metadata. The BEAM format[3][4] seemed simple enough, so I decided to start by trying to remove unneeded sections. After some experimentation, I found that the program only required 6 sections to function. This worked quite well, and I was immediately able to reduce the file from 740 to 384 bytes.

Technique 2: starting with a BEAM assembly file

While looking at the stripped file, I noticed some extra functions had been added by the compiler. The module_info/0 and module_info/1 functions are wrappers around erlang:get_module_info, and are not needed to run the program. I was able to ask the compiler to create an Erlang assembly file using the -S flag

I removed all code from the first module_info declaration down, and removed these functions from the exports table at the top. I also experimented with removing other instructions, but was only able to remove the line instruction directly below the first label. I also adjusted the labels attribute, although this doesn't seem to be required. This resulted in the follwing assembly file:

This compiles to a 592 byte file, which goes down to 264 bytes after stripping unused sections:

Technique 3: Crafting a BEAM file from scratch

At this point, I had optimization ideas that didn't seem possible with compiler-enforced validation. Most of the sections are fairly easy to generate. Each section begins with a 4-byte tag, followed by a 4-byte length, and 4-byte-aligned payload.

3.1: Atom chunk (Atom/AtU8)

The Atom chunk contains the names of each atom used by the program. Atoms are special strings such as module names and function names. When referencing a function such as ssl:start/0, the Atom section will contain "ssl" and "start", and these strings will be referenced by an index into the atom table. Each atom is a length-prefixed string up to 256 bytes, and the total count of atoms is stored at the start of the chunk. The Atom chunk was historically identified by the "Atom" tag, but this seems to have been superceded by the "AtU8" tag, indicating a UTF-8 Atom table. Lastly, the first entry in the Atom table must be the module name corresponding to the beam file.

3.2: String Table (StrT)

This section contains certain string literals. You may assume that this would be where the "https://binary.golf/5/5" string would be stored, but in fact that's actually stored in the literal table (section 3.5). The String Table must be present, but can be empty in this case. An empty string table is simply `b"StrT\0\0\0\0"`.

3.3: Import Table (ImpT)

Erlang function signatures are in the form `[module]:[function]/arity`. The module and function name are stored in the Atom table, and the arity is an integer corresponding to the number of arguments the function expects. The Import Table begins with the count of imports, followed by a sequence of (module, function, arity) fields (32-bit per field, or 12 bytes per import). The module and function names are indices into the atom table, starting at 1 for the first element.

3.4: Export Table (ExpT)

The Export Table is similar to the Import Table, but instead of storing the module name, the label of the function entrypoint is stored. The section starts with a count of exports, followed by a sequence of (function, arity, label) fields (32-bit per field, or 12 bytes per export).

3.5: Literal Table (LitT)

The Literal Table consists of an Erlang External Term Format (ETF) encoded list of literals. Unlike the string table or atom table, ETF encodes type information as well as value. The encoded data is compressed with Zlib, and the uncompressed size is stored alongside the compressed data. This already encoded only a single literal (the URL), and I found no way to shrink it further, so I simply copied the raw body of the literal table from my previous .beam files.

3.6: Code Section (Code)

The Code section is much more complicated than other sections, and I found The BEAM Book [6] to be very helpful for understanding the instruction format. The code section begins with a header containing the following 32-bit fields:

This header is immediately followed by the bytecode.

Optimizations

The first optimization focused on the imports table. I realized I could remove the import of `q:q/1` by calling httpc:request/1 with the non-string HTTP response returned by httpc:request/1. The error message contained the response headers and body just like the previous error message. This also allowed me to remove the `q` atom from the atoms table by renaming the module and main function to match other atoms. I went with renaming the main function to `start:inets/0`.

Next, I worked on optimizing the bytecode, and I was able to save a byte by replacing the call_ext_last at the end of the function with a call_ext, and removing the allocate instruction. The compiler enforced the consistency of the memory allocation, but the BEAM VM doesn't seem to care.

These optimizations bring the file size down to 248 bytes.

Technique 4: Compression

After doing all of this work, I found out that .beam files support compression. Gzipping the file saves another 41 bytes, bringing the final size down to 207 bytes.

Building the final submission: mkbeam.py

Running the Code

Some headers have been removed from the output for privacy.

Dissected Uncompressed BEAM file: start.beam.xx

References

[1] haxrob's .erl BGGP5 Entry

[2] Binary Golf Grand Prix 5

[3] BEAM File Format

[4] File Format for BEAM R5 and Later

[5] Erlang BEAM VM Specification

[6] The Beam Book

Proxied content from gemini://gem.hacking.rip/bggp5.gmi

Gemini request details:

Original URL
gemini://gem.hacking.rip/bggp5.gmi
Status code
Success
Meta
text/gemini;lang=en-US
Proxied by
kineto

Be advised that no attempt was made to verify the remote SSL certificate.

this site is part of the [haunted webring]