2025 summer challenge writeup

Written by Timothée Schneider-Maunoury - 12/09/2025 - in Challenges - Download

Last month we organised the Synacktiv Summer Challenge 2025, an event featuring an original challenge based on Podman archive formats. Many of you spent several hours working on it: we received over thirty attempts! This article aims to present and explain in detail the different steps involved in designing an optimal solution.

Looking to improve your skills? Discover our trainings sessions! Learn more.

Final leaderboard

Congratulations to the 9 participants who successfully submitted a valid solution. Here is the ranking, including each participant's score:

  • #1 johndoe - 229.00
  • #2 XeR - 233.99
  • #3 ioonag - 395.18
  • #4 taylorDeDordogne - 1661
  • #5 a00n - 1712
  • #6 xarkes - 4738
  • #7 k8pl3r - 18432
  • #8 julesdecube - 42099.68
  • #9 quent - 555520

We would like to express our special thanks to the winner for his generosity. He wished to remain anonymous and chose to donate the equivalent of the first prize, €200, to Médecins Sans Frontières! He is also the only player to achieve the challenge bonus, beating our internally designed solution, which had a score of 229.97:

$ echo -n "[>] average score over 500 tests -> 229.97" | sha256sum
c795ecf7692319832a62567ebdca26f4a7128197185bb019a1a139ad3b37ca58  -

 

OCI or Docker archive?

According to the podman load command man:

podman load loads an image from either an oci-archive or a docker-archive [...] podman load is used for loading from the archive generated by podman save

The most straightforward way to begin our experiments is to use the podman save command, on the hello-world:latest image for example, to generate an archive in oci-archive format and another in docker-archive format.

We observe that in both cases, these are simple tar archives containing:

  • A sub-archive for each layer that makes up the image's file system. In the case of hello-world, there is only one, and it just contains the small hello executable.
  • JSON files that describe all the metadata associated with the image, including the layer list, tags, and image entrypoint.

Specifications for the Docker Image v1.x format are defined in this git repository, and those for the Open Container Initiative (OCI) format can be found in this one. At first glance, it is not easy to determine which of these formats is the best for achieving the lowest score, as both can be used to produce very small final archives. However, the oci-archive format has the drawback of enforcing the Content Addressed Storage structure in the blobs directory, which adds significant overhead.

Therefore, we will focus on the historical Docker Image format version V1.3:

$ podman save --format docker-archive -o test.tar hello-world:latest
Copying blob 53d204b3dc5d done
Copying config 1b44b5a3e0 done
Writing manifest to image destination
Storing signatures

$ tar xvf test.tar
53d204b3dc5ddbc129df4ce71996b8168711e211274c785de5e0d4eb68ec3851.tar
1b44b5a3e06a9aae883e7bf25e45c100be0bb81a0e01b32de604f3ac44711634.json
ccbb50ff49d360a84143aae385758520507df1c64e403698b61b91aa9d5d3f41/layer.tar
ccbb50ff49d360a84143aae385758520507df1c64e403698b61b91aa9d5d3f41/VERSION
ccbb50ff49d360a84143aae385758520507df1c64e403698b61b91aa9d5d3f41/json
manifest.json
repositories

$ tar xvf 53d204b3dc5ddbc129df4ce71996b8168711e211274c785de5e0d4eb68ec3851.tar
hello

The blob 53d204b3dc5d, corresponding to the image's single layer, and the configuration 1b44b5a3e0 are indeed contained in the archive, as is the manifest.json file. The specification states that the repositories file and the ccbb50[...]3f41/ directory are only provided for backward compatibility reasons, which means we can ignore them.

 

The format details

The main JSON file is the manifest.json. It consists of a list of dictionaries, each one associating a configuration file with a list of tags and a list of layers. In the case of the hello-world image, there is only one element that defines a tag and a layer.

[
  {
    "Config": "1b44b5a3e06a9aae883e7bf25e45c100be0bb81a0e01b32de604f3ac44711634.json",
    "RepoTags": [
      "docker.io/library/hello-world:latest"
    ],
    "Layers": [
      "53d204b3dc5ddbc129df4ce71996b8168711e211274c785de5e0d4eb68ec3851.tar"
    ]
  }
]

The configuration file called Image JSON Description provides much more information. However, by manipulating this file and gradually removing data, we realize that the only fields that are really necessary are:

  • The "entrypoint" or "cmd" value in the "config" dictionary: it defines the executable that will be started when running a podman run on this image.
  • The "diff_ids" field in the "rootfs" dictionary: this list must contain the hashes of every image layer. Podman will check that the hashes match and, if they don't, refuse to load the image with the error "Digest did not match".
{
  "architecture": "amd64",
  "config": {
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/hello"
    ],
    "WorkingDir": "/"
  },
  "created": "2025-08-08T19:05:17Z",
  "history": [
    {
      "created": "2025-08-08T19:05:17Z",
      "created_by": "COPY hello / # buildkit",
      "comment": "buildkit.dockerfile.v0"
    },
    {
      "created": "2025-08-08T19:05:17Z",
      "created_by": "CMD [\"/hello\"]",
      "comment": "buildkit.dockerfile.v0",
      "empty_layer": true
    }
  ],
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:53d204b3dc5ddbc129df4ce71996b8168711e211274c785de5e0d4eb68ec3851"
    ]
  }
}

 

A first solution

At this point, we have all the information we need to design a "naive" solution. The simplest approach is to choose a programming language that allows us to compile a static binary so that our image layer contains only one entry. The complete C++ code is presented at the end of this article.

The code must implement the following actions:

  1. Read its own binary file using the path /proc/self/exe.
  2. Create a tar archive containing this file, which requires first writing the header and then the data retrieved in step 1.
  3. Calculate the SHA256 fingerprint of this layer.
  4. Build the final docker-archive with:
    1. the tar archive created in step 2,
    2. the configuration file, which consists of:
      1. the image entrypoint, which will be the name of the binary archived in step 2,
      2. the layer hash calculated in step 3;
    3. the manifest.json file, which defines:
      1. the tag, passed as an argument to the program,
      2. the name of the archive added in step 4.1,
      3. the name of the configuration file created in step 4.2.
  5. And finally, write the entire docker-archive to the standard output stdout.

To generate our first self-replicating archive and successfully pass the test script, simply run this program with the tag "latest" as a parameter!

 

Layer caching

We can now take a look at the result of the test script running on this first solution, after removing the --quiet option to get more logs.

Getting image source signatures
Copying blob f9c938e97f5c done  
Copying config 9b5b3b2204 done  
Writing manifest to image destination
Storing signatures
Loaded image: localhost/ocinception_c:latest
Getting image source signatures
Copying blob f9c938e97f5c skipped: already exists  
Copying config 9b5b3b2204 done  
Writing manifest to image destination
Storing signatures
Loaded image: localhost/ocinception_c:701bdcf28f43d13c24682fc75cad698c96c882c4441b46a2577697b1f830d343
[...]

We observe a very significant difference between the first podman load output and the second one: the message skipped: already exists on the copy of the image's main layer, the one that contains the binary.
As we explained earlier, when Podman loads an image, it starts by reading the manifest.json file, then the related configuration file, where the diff_ids list of layers' hashes is defined. However, Podman has a caching mechanism that saves time by avoiding copying a layer whose hash is already present in its storage.
In our case, with an overlay storage configured, we can actually see the layer on our host, in the directory ~/.local/share/containers/storage/overlay/f9c938e97f5c393eb699303389f93fff1ebe08f5a39982fcf25cbeea3035c16f.
Thus, the main optimization of the challenge can be implemented by exploiting the Podman cache, which allows the archive to be stripped of its heaviest entry: the binary itself.

The code updates are minimal: you simply need to check whether the tag passed as an argument is equal to "latest". If it's different, then the execution does not correspond to the first podman load and the layer is already present in the cache. In this case, we do not add the layer to the final archive, which now only contains the two JSON files.

 

Some improvements

In order to discover additional techniques to further reduce the score, we had to explore the limitations of the Tar and JSON parsers used by Podman. It was also possible to find some ideas in Podman's source code or in the Go developer documentation.

Here is the output of the hexdump command on our final archive. This command was very useful for visualising our results and adjusting each byte. This section explains all the optimisations that appear in the following illustration:

$ hexdump -C final_ocinception_c.tar
00000000  6d 61 6e 69 66 65 73 74  2e 6a 73 6f 6e 00 00 00  |manifest.json...| == manifest.json header ==
00000010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000080  00 00 00 00 32 30 31 00  00 00 00 00 00 00 00 00  |....201.........| -> Opti 3.
00000090  00 00 00 00 00 00 33 33  32 32 00 00 00 00 00 00  |......3322......|
000000a0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000200  5b 7b 22 72 65 70 6f 74  61 67 73 22 3a 5b 22 6f  |[{"repotags":["o| == manifest.json data ==
00000210  63 69 6e 63 65 70 74 69  6f 6e 5f 63 3a 65 30 65  |cinception_c:e0e| -> Opti 5.
00000220  34 61 65 64 66 62 38 31  35 61 61 33 39 35 35 61  |4aedfb815aa3955a|
00000230  64 32 31 33 34 39 33 36  61 38 64 33 31 64 39 34  |d2134936a8d31d94|
00000240  62 65 39 64 31 33 32 35  65 37 65 38 34 31 65 37  |be9d1325e7e841e7|
00000250  34 65 35 33 36 38 31 66  39 61 62 39 34 22 5d 2c  |4e53681f9ab94"],|
00000260  22 6c 61 79 65 72 73 22  3a 5b 22 22 5d 7d 5d 20  |"layers":[""]}] | -> Opti 4.
00000270  20 20 20 20 20 20 20 20  20 20 20 20 20 20 20 20  |                |
00000280  20 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  | ...............|
00000290  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000400  00 6d 61 6e 69 66 65 73  74 2e 6a 73 6f 6e 00 00  |.manifest.json..| == config header ==
00000410  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................| -> Opti 4.
*                                                                                 
00000480  00 00 00 00 32 30 31 00  00 00 00 00 00 00 00 00  |....201.........| -> Opti 3.
00000490  00 00 00 00 00 00 33 33  32 32 00 00 00 00 00 00  |......3322......|
000004a0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000600  7b 22 63 6f 6e 66 69 67  22 3a 7b 22 65 6e 74 72  |{"config":{"entr| == config data ==
00000610  79 70 6f 69 6e 74 22 3a  5b 22 63 22 5d 7d 2c 22  |ypoint":["c"]},"| -> Opti 2.
00000620  72 6f 6f 74 66 73 22 3a  7b 22 64 69 66 66 5f 69  |rootfs":{"diff_i|
00000630  64 73 22 3a 5b 22 73 68  61 32 35 36 3a 65 35 64  |ds":["sha256:e5d|
00000640  36 66 65 35 66 37 65 39  61 37 64 35 61 62 37 37  |6fe5f7e9a7d5ab77|
00000650  66 61 34 38 38 33 39 35  30 39 33 33 62 61 34 34  |fa4883950933ba44|
00000660  37 38 61 33 64 66 66 30  62 33 37 65 33 34 34 61  |78a3dff0b37e344a|
00000670  63 66 39 34 38 66 33 65  32 36 61 31 64 22 5d 7d  |cf948f3e26a1d"]}|
00000680  7d                                                |}|                -> Opti 1.
00000681
  1. Normally, a tar archive must end with two blocks of 512 null bytes, but we can truncate them without triggering a Podman error.
  2. The main layer binary can be put in the "/bin" directory, where Podman will look for binaries to execute by default. This allows us to remove the '/' in the entrypoint value.
  3. To accept a tar archive, Podman needs very few of the information included in files' headers. We can therefore define the minimum required, i.e. the file name, its size and the header checksum.
  4. One of the reasons why the winner and XeR achieved such good scores is thanks to the following technique. It involves adding the right number of spaces, or a nickname of the right length, so that manifest.json is exactly the same size as the configuration file. The string “manifest.json” must also be added to the configuration file header. This makes the headers of the two files almost identical (except for two bytes), and they compress much better!
  5. The archive can contain a file whose name is an empty string! In addition, the JSON parser leaves an empty string if one of the fields is missing. The combination of these two behaviours allows the "config" field to be omitted from the manifest.

Finally, compressing the archive saves us a significant amount of space! Podman accepts several different formats, the best algorithm in our case being Zstandard. By removing checksum and content size, the zstd header is particularly small, and if we set the level to 22 (ultra), we get a remarkably efficient compression.

 

Hash bruteforce

Our layer hash must be present in the configuration, and depending on the bytes it contains, it may be more or less effectively compressed by zstd. We can write a small bash script that modifies the binary by adding a counter to the source code. For each repetition, the script calculates the average score with 10 random tags. In our tests, we saved 3 bytes on the size of the compressed archive after only a few hundred iterations.

#!/bin/bash
set -e

OUTPUT=bf_results.txt
min_score=235.0
BINARY_NAME=main_exe

for ((i = 0; i < 1000000; i++)); do
    if (( i % 1000 == 0 )); then
        echo "[!] iteration $i" >> $OUTPUT
    fi

    # Update a counter to change the resulting hash
    g++ -DCOUNTER=\"$i\" -static -O3 -o $BINARY_NAME best.cpp -lzstd -lcrypto -Wno-deprecated-declarations
    
    # Compute the score over 10 executions
    sum=0
    loop_count=10
    for ((j = 0; j < loop_count; j++)); do
        random_tag=$(head -c 32 /dev/urandom | sha256sum | awk '{print $1}')
        current_score=$(./$BINARY_NAME $random_tag | wc -c)
        sum=$(echo "$sum + $current_score" | bc)
    done
    score=$(echo "scale=1; $sum / $loop_count" | bc)

    # Add score to result file if it's a good one
    if (( $(echo "$score <= $min_score" | bc -l) )); then
        min_score=$score
        echo "[>] iteration $i -> $score" >> $OUTPUT
    fi
done

 

A solution example

Here is a C++ code that implements all of the optimizations presented in this article. This solution achieves an average score of around 227.

#include <cstring>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <openssl/sha.h>
#include <vector>
#include <zstd.h>

#ifdef COUNTER
const char *counter = COUNTER; // Used for layer hash bruteforce
#endif

#ifndef NICKNAME
#define NICKNAME "c"
#endif

using namespace std;
const char null_bytes[1024] = {0};

// Calculate a sha256sum of a stream data
string calculate_sha256sum(istream &stream)
{
    stringstream result;
    const size_t buffer_size = 4096;
    char buffer[buffer_size];
    unsigned char hash[SHA256_DIGEST_LENGTH];

    // Use openssl sha256 context to get data hash
    SHA256_CTX sha256_ctx;
    SHA256_Init(&sha256_ctx);
    while (stream.read(buffer, buffer_size) || stream.gcount() > 0)
    {
        SHA256_Update(&sha256_ctx, buffer, stream.gcount());
    }
    SHA256_Final(hash, &sha256_ctx);

    // Convert raw hash to hexadecimal string
    for (int i = 0; i < SHA256_DIGEST_LENGTH; i++)
    {
        result << hex << setw(2) << setfill('0') << (int)hash[i];
    }
    return result.str();
}

// Add a file to the tar archive
void add_file_to_tar(
    ostream &tar_file, const string &file_name, const string &file_content,
    bool is_minimal_header = false, bool do_padding = true)
{
    const size_t file_size = file_content.size();

    // First, create header
    char header[512] = {0};
    strncpy(header, file_name.c_str(), 100);                                     // File name
    snprintf(header + 124, 12, "%011lo", static_cast<unsigned long>(file_size)); // File size in bytes (octal)

    // Add manifest.json string in config file header to improve compression
    if (file_name.empty())
    {
        strncpy(header + 1, "manifest.json", 16);
    }

    if (is_minimal_header)
    {
        memset(header + 124, 0x00, 8); // Add null bytes to save place
    }
    else
    {
        snprintf(header + 100, 8, "%07o", 0755); // File mode (octal)
    }

    // Calculate the header cheksum
    memset(header + 148, ' ', 8); // Fill the checksum field with spaces before calculation
    unsigned int checksum = 0;
    for (int i = 0; i < 512; ++i)
    {
        checksum += (unsigned char)header[i];
    }
    snprintf(header + 148, 8, "%06o", checksum); // Insert the calculated checksum

    // Add null bytes to save place
    memset(header + 148, 0x00, 2);
    memset(header + 154, 0x00, 2);
    if (header[150] == '0')
    {
        header[150] = 0x00;
    }

    // Then, write file header and data
    tar_file.write(header, 512);
    tar_file.write(file_content.c_str(), file_size);

    // Add padding if needed
    if (do_padding)
    {
        tar_file.write(null_bytes, (512 - file_size % 512) % 512);
    }
}

void zstd_compress(string tar_archive_data, vector<char> &compressed_data)
{
    // Create a zstd compression context with the best parameters
    ZSTD_CCtx *const cctx = ZSTD_createCCtx();
    ZSTD_CCtx_setParameter(cctx, ZSTD_c_compressionLevel, 22); // Maximum compression level
    ZSTD_CCtx_setParameter(cctx, ZSTD_c_contentSizeFlag, 0);   // Disable content size in the header
    ZSTD_CCtx_setParameter(cctx, ZSTD_c_checksumFlag, 0);      // Disable checksum in the header

    // Perform the compression
    const size_t compression_buff_size = ZSTD_compressBound(tar_archive_data.size());
    compressed_data.resize(compression_buff_size);
    const size_t compressed_size = ZSTD_compress2(
        cctx, compressed_data.data(), compression_buff_size, tar_archive_data.data(), tar_archive_data.size());
    compressed_data.resize(compressed_size);
    ZSTD_freeCCtx(cctx);
}

int main(int argc, char *argv[])
{
    // Check tag argument
    if (argc < 2)
    {
        cerr << "Usage: " << argv[0] << " <tag>" << endl;
        return 1;
    }

    const string tag_string = argv[1];
    const bool is_initial_load = tag_string == "latest";
    const string file_name = NICKNAME;
    const string config_name = "";
    const string manifest_name = "manifest.json";
    const string layer_string = is_initial_load ? "layer.tar" : config_name;

    ifstream inFile("/proc/self/exe", ios::binary); // Read self program binary file
    vector<char> program_data((istreambuf_iterator<char>(inFile)), istreambuf_iterator<char>());
    inFile.close();

    // Add self binary file in tar archive, inside "bin" directory
    ostringstream hash_stream;
    add_file_to_tar(hash_stream, "bin/" + file_name, string(program_data.data(), program_data.size()));
    hash_stream.write(null_bytes, 512 * 2); // Write two empty blocks to end the tar archive

    // Calculate resulting tar archive checksum, in order to include it in config content
    istringstream input_stream(hash_stream.str());
    const string layer_checksum = calculate_sha256sum(input_stream);

    // Create files for the new archive
    const string config_content =
        "{\"config\":{\"entrypoint\":[\"" + file_name +
        "\"]},\"rootfs\":{\"diff_ids\":[\"sha256:" + layer_checksum + "\"]}}";
    const string manifest_content =
        "[{\"repotags\":[\"ocinception_" + file_name +
        ":" + tag_string + "\"],\"layers\":[\"" + layer_string + "\"]}]" +
        "                  "; // Add padding to match config file header

    ostringstream new_tar_stream;
    if (is_initial_load) // No need to add main layer if it's already in podman cache
    {
        add_file_to_tar(new_tar_stream, layer_string, hash_stream.str());
    }
    // Add config and manifest files in final tar archive
    add_file_to_tar(new_tar_stream, manifest_name, manifest_content, true);
    add_file_to_tar(new_tar_stream, config_name, config_content, true, false);

    vector<char> compressed_data;
    zstd_compress(new_tar_stream.str(), compressed_data); // zstd best compression

    // Write the compressed data to stdout
    cout.write(compressed_data.data(), compressed_data.size());
    return 0;
}

This code can be compiled with the following command, where the value of COUNTER has been determined using the brute force script presented above:
g++ -static -O3 -DNICKNAME=\"c\" -DCOUNTER=\"424\" -o best_solution best.cpp -lzstd -lcrypto -Wno-deprecated-declarations

To generate the expected solution, the binary must be executed with the "latest" argument:
./best_solution latest > ocinception_c.tar

 

Conclusion

Thanks again to all participants!

Starting with an initial archive of a few megabytes, we managed to shrink it down to just 227 bytes. This result was achieved by leveraging Podman's internal behavior when loading an image. We optimized at several levels: exploiting the tar parser to strip out unnecessary parts, minimizing the JSON to the bare essentials, and making effective use of layer caching and of compression.

After reading this article, if you have any ideas or suggestions for further improve the solution, feel free to share them with us at summer-challenge@synacktiv.com! We are again offering the Keychron keyboard as a prize, it will be won by the first person to reach the symbolic threshold of 200 bytes 🎁