2025 summer challenge writeup
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:
- Read its own binary file using the path /proc/self/exe.
- Create a tar archive containing this file, which requires first writing the header and then the data retrieved in step 1.
- Calculate the SHA256 fingerprint of this layer.
- Build the final docker-archive with:
- the tar archive created in step 2,
- the configuration file, which consists of:
- the image entrypoint, which will be the name of the binary archived in step 2,
- the layer hash calculated in step 3;
- the manifest.json file, which defines:
- the tag, passed as an argument to the program,
- the name of the archive added in step 4.1,
- the name of the configuration file created in step 4.2.
- 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
- Normally, a tar archive must end with two blocks of 512 null bytes, but we can truncate them without triggering a Podman error.
- 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.
- 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.
- 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!
- 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 🎁